Backup before continuing from Codex 5.2 session - User storage, compaction suggestions, streaming improvements

This commit is contained in:
Gemini AI
2025-12-24 21:27:05 +04:00
Unverified
parent f9748391a9
commit e8c38b0add
93 changed files with 10615 additions and 2037 deletions

View File

@@ -0,0 +1,140 @@
## Implementation Plan: Enhanced Session Compaction System (9 High-Priority Fixes)
### Phase 1: Core Foundation (Types & Configuration)
**NEW: `packages/ui/src/stores/session-compaction.ts`**
1. **Compaction Types & Interfaces**
- `CompactionMessageFlags`: summary, mode, provenance flags
- `StructuredSummary`: Tier A/B schema with what_was_done, files, current_state, key_decisions, next_steps, blockers, artifacts, tags, provenance
- `CompactionEvent`: Audit trail with event_id, timestamp, actor, trigger_reason, token_before/after, model_used, cost_estimate
- `CompactionConfig`: autoCompactEnabled, autoCompactThreshold, compactPreserveWindow, pruneReclaimThreshold, userPreference, undoRetentionWindow
- `SessionCompactingHook`: Plugin contract for domain-specific rules
2. **Configuration Store**
- Default config: auto=80%, preserve=40k tokens, prune_threshold=20k, preference="ask"
- Export functions: `getCompactionConfig()`, `updateCompactionConfig()`
### Phase 2: Overflow Detection Engine
**MODIFY: `packages/ui/src/stores/session-compaction.ts`**
3. **Token Monitoring Functions**
- `isOverflowDetected(usage, modelLimit)`: Check if usage >= threshold%
- `shouldPruneToolOutputs(usage)`: Check if tool outputs > reclaim threshold
- `estimateTokenReduction(before, after)`: Calculate % reduction
4. **Audit Trail System**
- `recordCompactionEvent(sessionId, event)`: Append-only to audit log
- `getCompactionHistory(sessionId)`: Retrieve audit trail
- `exportAuditLog()`: For compliance/debugging
### Phase 3: Secrets Detection & Sanitization
**NEW: `packages/ui/src/lib/secrets-detector.ts`**
5. **Secrets Detector**
- Pattern matching for: api keys, passwords, tokens, secrets, credentials
- `redactSecrets(content)`: Returns { clean: string, redactions: { path, reason }[] }
- Placeholder format: `[REDACTED: {reason}]`
### Phase 4: AI-Powered Compaction Agent
**MODIFY: `packages/ui/src/stores/session-compaction.ts`**
6. **Compaction Agent Integration**
- `COMPACTION_AGENT_PROMPT`: Structured prompt with instructions
- `generateCompactionSummary(instanceId, sessionId, window)`: Call sendMessage() to get AI summary
- Parse response into Tier A (human) and Tier B (structured JSON)
7. **Execute Compaction**
- `executeCompaction(instanceId, sessionId, mode)`: Main compaction orchestration
- Steps: enumerate → plugin hooks → AI summary → sanitize → store → prune → audit
- Returns: preview, token estimate, compaction event
### Phase 5: Pruning Engine
**MODIFY: `packages/ui/src/stores/session-compaction.ts`**
8. **Sliding Window Pruning**
- `pruneToolOutputs(instanceId, sessionId)`: Maintain queue, prune oldest > threshold
- `isToolOutput(part)`: Classify build logs, test logs, large JSON
### Phase 6: Undo & Rehydration
**MODIFY: `packages/ui/src/stores/session-compaction.ts`**
9. **Undo System**
- `undoCompaction(sessionId, compactionEventId)`: Rehydrate within retention window
- `getCompactedSessionSummary(sessionId)`: Retrieve stored summary
- `expandCompactedView(sessionId)`: Return archived messages
### Phase 7: Integration
**MODIFY: `packages/ui/src/stores/session-events.ts`**
10. **Auto-Compact Trigger**
- Monitor `EventSessionUpdated` for token usage
- Trigger based on user preference (auto/ask/never)
- Call existing `showConfirmDialog()` with compaction preview
**MODIFY: `packages/ui/src/stores/session-actions.ts`**
11. **Replace compactSession**
- Use new `executeCompaction()` function
- Support both "prune" and "compact" modes
### Phase 8: Schema Validation
**NEW: `packages/ui/src/lib/compaction-validation.ts`**
12. **Schema Validation**
- `validateStructuredSummary(summary)`: Zod schema for Tier B
- `validateCompactionEvent(event)`: Zod schema for audit trail
- `ValidationErrors` type with path, message, code
### Phase 9: CI Tests
**NEW: `packages/ui/src/stores/session-compaction.test.ts`**
13. **Test Coverage**
- `test_overflow_detection`: Verify threshold calculation
- `test_secrets_redaction`: Verify patterns are caught
- `test_compaction_execution`: Full compaction flow
- `test_undo_rehydration`: Verify restore works
- `test_plugin_hooks`: Verify custom rules apply
### Phase 10: Canary Rollout
**MODIFY: `packages/ui/src/stores/session-compaction.ts`**
14. **Feature Flag**
- `ENABLE_SMART_COMPACTION`: Environment variable or config flag
- Default: `false` for canary, set to `true` for full rollout
- Graceful degradation: fall back to simple compaction if disabled
---
## Implementation Order (Priority)
1. **P0 - Foundation**: Types, config, schema validation (1-2, 12)
2. **P0 - Core Engine**: Overflow detection, secrets detector (3-5)
3. **P0 - AI Integration**: Compaction agent, execute function (6-7)
4. **P1 - Pruning**: Tool output classification, sliding window (8)
5. **P1 - Undo**: Rehydration system (9)
6. **P1 - Integration**: Session events, actions integration (10-11)
7. **P2 - Tests**: CI test coverage (13)
8. **P2 - Rollout**: Feature flag, canary enablement (14)
---
## Success Criteria
- ✅ AI generates meaningful summaries (not just "0 AI responses")
- ✅ Overflow detected before context limit exceeded
- ✅ Secrets are redacted before storage
- ✅ Audit trail tracks every compaction
- ✅ Undo works within retention window
- ✅ Schema validation prevents corrupt data
- ✅ CI tests ensure reliability
- ✅ Canary flag allows safe rollout

View File

@@ -0,0 +1,391 @@
# FINAL EXECUTION PLAN - 8 Fixes with Proof Deliverables
## Fix Summary
| Fix | Files | Deliverables |
|------|--------|-------------|
| C1 | Install-Windows.bat, Install-Mac.sh, Install-Linux.sh, Launch-Windows.bat, Launch-Dev-Windows.bat, Launch-Unix.sh | 9 path diffs + `dir packages\ui\dist` verification |
| C2 | packages/ui/vite.config.ts, Launch-Dev-Windows.bat, Launch-Dev-Unix.sh (NEW) | vite.config.ts diff + 2 launcher diffs + Vite log showing port |
| C3 | Launch-Windows.bat, Launch-Dev-Windows.bat, Launch-Unix.sh | 3 CLI_PORT env var diffs + server log showing port |
| C4 | Install-Windows.bat, Install-Mac.sh, Install-Linux.sh | 3 download/checksum diffs + log verification |
| C5 | Install-Windows.bat | Certutil parsing diff + hash output |
| C6 | Install-Windows.bat, Install-Mac.sh, Install-Linux.sh | 3 TARGET_DIR/BIN_DIR diffs + fallback test output |
| C7 | Install-Windows.bat, Install-Mac.sh, Install-Linux.sh | 3 health check path diffs + health check output |
| C8 | Launch-Dev-Windows.bat | 1 path diff + grep verification |
---
## C1: UI Build Path Correction
**Files:** Install-Windows.bat (lines 194, 245), Install-Mac.sh (204, 256), Install-Linux.sh (220, 272), Launch-Windows.bat (185), Launch-Dev-Windows.bat (144), Launch-Unix.sh (178)
**Diff:**
```batch
# All Windows scripts - replace:
packages\ui\src\renderer\dist
# With:
packages\ui\dist
# All Unix scripts - replace:
packages/ui/src/renderer/dist
# With:
packages/ui/dist
```
**Verification:** `dir packages\ui\dist` + `dir packages\ui\dist\index.html`
---
## C2: Vite Dev Server Port Wiring
**File 1: packages/ui/vite.config.ts (line 23)**
```diff
- server: {
- port: 3000,
- },
+ server: {
+ port: Number(process.env.VITE_PORT ?? 3000),
+ },
```
**File 2: Launch-Dev-Windows.bat (after port detection)**
```diff
- start "NomadArch UI" cmd /k "cd /d \"%~dp0packages\ui\" && set VITE_PORT=!UI_PORT! && npm run dev"
+ start "NomadArch UI" cmd /k "cd /d \"%~dp0packages\ui\" && set VITE_PORT=!UI_PORT! && npm run dev -- --port !UI_PORT!"
```
**File 3: Launch-Dev-Unix.sh (NEW FILE)**
```bash
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Port detection
DEFAULT_SERVER_PORT=3001
DEFAULT_UI_PORT=5173
SERVER_PORT=$DEFAULT_SERVER_PORT
UI_PORT=$DEFAULT_UI_PORT
echo "[INFO] Detecting available ports..."
# Server port (3001-3050)
for port in {3001..3050}; do
if ! lsof -i :$port -sTCP:LISTEN -t > /dev/null 2>&1; then
SERVER_PORT=$port
break
fi
done
# UI port (5173-5200)
for port in {5173..5200}; do
if ! lsof -i :$port -sTCP:LISTEN -t > /dev/null 2>&1; then
UI_PORT=$port
break
fi
done
echo "[INFO] Using server port: $SERVER_PORT"
echo "[INFO] Using UI port: $UI_PORT"
# Start server with CLI_PORT
echo "[INFO] Starting Backend Server..."
cd packages/server
export CLI_PORT=$SERVER_PORT
npm run dev &
SERVER_PID=$!
sleep 3
# Start UI with VITE_PORT + --port flag
echo "[INFO] Starting Frontend UI..."
cd "$SCRIPT_DIR/packages/ui"
export VITE_PORT=$UI_PORT
npm run dev -- --port $UI_PORT &
UI_PID=$!
sleep 3
# Start Electron
echo "[INFO] Starting Electron..."
cd "$SCRIPT_DIR/packages/electron-app"
npm run dev
# Cleanup on exit
trap "kill $SERVER_PID $UI_PID 2>/dev/null; exit" INT TERM
```
**Verification:** Vite log output showing `Local: http://localhost:<detected_port>`
---
## C3: Server Port Environment Variable
**Launch-Windows.bat (before npm run dev:electron):**
```diff
echo [INFO] Starting NomadArch...
set SERVER_URL=http://localhost:!SERVER_PORT!
echo [INFO] Server will run on http://localhost:!SERVER_PORT!
+
+ set CLI_PORT=!SERVER_PORT!
call npm run dev:electron
```
**Launch-Dev-Windows.bat (server start command):**
```diff
echo [INFO] Starting Backend Server...
- start "NomadArch Server" cmd /k "cd /d \"%~dp0packages\server\" && npm run dev"
+ start "NomadArch Server" cmd /k "cd /d \"%~dp0packages\server\" && set CLI_PORT=!SERVER_PORT! && npm run dev"
```
**Launch-Unix.sh (before npm run dev:electron):**
```bash
echo -e "${GREEN}[INFO]${NC} Starting NomadArch..."
SERVER_URL="http://localhost:$SERVER_PORT"
echo -e "${GREEN}[INFO]${NC} Server will run on http://localhost:$SERVER_PORT"
export CLI_PORT=$SERVER_PORT
npm run dev:electron
```
**Verification:** Server log showing `CodeNomad Server is ready at http://127.0.0.1:<detected_port>`
---
## C4: OpenCode Download with Dynamic Version + Checksum
**Install-Windows.bat (lines 165-195):**
```batch
set TARGET_DIR=%SCRIPT_DIR%
set BIN_DIR=%TARGET_DIR%\bin
if not exist "%BIN_DIR%" mkdir "%BIN_DIR%"
:: Resolve latest version from GitHub API
echo [INFO] Resolving latest OpenCode version...
for /f "delims=" %%v in ('curl -s https://api.github.com/repos/sst/opencode/releases/latest ^| findstr "\"tag_name\""') do (
set OPENCODE_VERSION=%%v
set OPENCODE_VERSION=!OPENCODE_VERSION:~18,-2!
)
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
) else (
echo [INFO] Downloading OpenCode v%OPENCODE_VERSION%...
echo Downloading from: %OPENCODE_URL%
:: Download binary to BIN_DIR
curl -L -o "%BIN_DIR%\opencode.exe.tmp" "%OPENCODE_URL%"
if %ERRORLEVEL% neq 0 (
echo [ERROR] Download failed!
set /a ERRORS+=1
goto :skip_opencode
)
:: Download checksums
curl -L -o "%BIN_DIR%\checksums.txt" "%CHECKSUM_URL%"
:: Extract expected checksum
set EXPECTED_HASH=
for /f "tokens=1,2" %%h in ('type "%BIN_DIR%\checksums.txt" ^| findstr /i "opencode-windows-%ARCH%"') do (
set EXPECTED_HASH=%%h
)
:: Calculate actual hash (line 2 from certutil)
set ACTUAL_HASH=
for /f "skip=1 tokens=*" %%h in ('certutil -hashfile "%BIN_DIR%\opencode.exe.tmp" SHA256 ^| findstr /v "CertUtil" ^| findstr /v "hash of"') do (
set ACTUAL_HASH=%%h
goto :hash_found
)
:hash_found
:: Verify and output hashes
echo Expected hash: !EXPECTED_HASH!
echo Actual hash: !ACTUAL_HASH!
if "!ACTUAL_HASH!"=="!EXPECTED_HASH!" (
move /Y "%BIN_DIR%\opencode.exe.tmp" "%BIN_DIR%\opencode.exe"
echo [OK] OpenCode downloaded and verified
echo [%date% %time%] OpenCode v%OPENCODE_VERSION% downloaded, checksum verified >> "%TARGET_DIR%\install.log"
) else (
echo [ERROR] Checksum mismatch!
del "%BIN_DIR%\opencode.exe.tmp"
set /a ERRORS+=1
)
)
:skip_opencode
```
**Install-Mac.sh / Install-Linux.sh:** Similar pattern with `opencode-darwin-${ARCH}` and `opencode-linux-${ARCH}`, using `TARGET_DIR/bin`
**Verification:** Log shows `OpenCode v<x.y.z> downloaded, checksum verified` + `ls TARGET_DIR/bin/opencode` exists
---
## C5: Windows Checksum Parsing
**Included in C4 above.** Key change:
```batch
:: Parse certutil output - hash is on line 2
for /f "skip=1 tokens=*" %%h in ('certutil -hashfile "%BIN_DIR%\opencode.exe.tmp" SHA256 ^| findstr /v "CertUtil" ^| findstr /v "hash of"') do (
set ACTUAL_HASH=%%h
goto :hash_found
)
```
**Verification:** Output shows matching hashes:
```
Expected hash: abc123def456...
Actual hash: abc123def456...
```
---
## C6: Permission Fallback with TARGET_DIR/BIN_DIR
**Install-Windows.bat (lines 125-160):**
```batch
set TARGET_DIR=%SCRIPT_DIR%
set BIN_DIR=%TARGET_DIR%\bin
set NEEDS_FALLBACK=0
echo [STEP 2/10] Checking Write Permissions...
echo.
echo. > "%SCRIPT_DIR%\test-write.tmp" 2>nul
if %ERRORLEVEL% neq 0 (
echo [WARN] Cannot write to current directory: %SCRIPT_DIR%
echo [INFO] Setting fallback for install outputs...
set TARGET_DIR=%USERPROFILE%\NomadArch-Install
set BIN_DIR=%TARGET_DIR%\bin
if not exist "%TARGET_DIR%" mkdir "%TARGET_DIR%"
if not exist "%BIN_DIR%" mkdir "%BIN_DIR%"
echo. > "%TARGET_DIR%\test-write.tmp" 2>nul
if %ERRORLEVEL% neq 0 (
echo [ERROR] Cannot write to fallback directory either!
set /a ERRORS+=1
goto :final_check
)
echo [OK] Using fallback for outputs: %TARGET_DIR%
echo [%date% %time%] Using fallback: %TARGET_DIR% >> "%TARGET_DIR%\install.log"
set NEEDS_FALLBACK=1
del "%TARGET_DIR%\test-write.tmp"
) else (
if not exist "%BIN_DIR%" mkdir "%BIN_DIR%"
del "%SCRIPT_DIR%\test-write.tmp"
echo [OK] Write permissions verified
)
:: All log writes use TARGET_DIR
set LOG_FILE=%TARGET_DIR%\install.log
```
**Install-Mac.sh / Install-Linux.sh:** Similar pattern with `TARGET_DIR=$HOME/.nomadarch-install`, `BIN_DIR=$TARGET_DIR/bin`
**Verification:** Run from read-only directory, output shows `Using fallback for outputs: C:\Users\xxx\NomadArch-Install`
---
## C7: Health Check Path Corrections
**Install-Windows.bat (health check section):**
```diff
:: UI health check
- if exist "%SCRIPT_DIR%\packages\ui\src\renderer\dist" (
+ if exist "%SCRIPT_DIR%\packages\ui\dist\index.html" (
echo [OK] UI build directory exists
) else (
- echo [ERROR] UI build directory not found
+ echo [ERROR] UI build directory not found at packages\ui\dist
set /a HEALTH_ERRORS+=1
)
:: Electron health check
- if exist "%SCRIPT_DIR%\packages\electron-app\dist\main.js" (
+ if exist "%SCRIPT_DIR%\packages\electron-app\dist\main\main.js" (
echo [OK] Electron main.js exists
) else (
echo [WARN] Electron build not found (will build on launch)
)
```
**Install-Mac.sh / Install-Linux.sh:** Same logic with shell syntax
**Verification:** Health check output:
```
[OK] UI build directory exists
[OK] Electron main.js exists
```
---
## C8: Launch-Dev-Windows Electron Path Fix
**Launch-Dev-Windows.bat line 162:**
```diff
- if not exist "electron-app\dist\main.js" (
+ if not exist "packages\electron-app\dist\main\main.js" (
```
**Verification:** `grep -n "electron-app" Launch-Dev-Windows.bat` shows no `electron-app\` references remaining
---
## Execution Order
1. C6 (TARGET_DIR/BIN_DIR) - Foundation for C4
2. C7 (Health checks) - Independent path fixes
3. C1 (UI paths) - Quick path replacements
4. C8 (Launch-Dev-Windows) - Quick path fix
5. C2 (Vite port) - Includes new file creation
6. C3 (Server port) - Quick env var changes
7. C4 (OpenCode download) - Depends on C6, includes C5
8. **Run build** for C1/C7 verification
---
## Verification Commands to Run
| Fix | Command | Expected Output |
|------|----------|----------------|
| C1 | `dir packages\ui\dist` | Shows `index.html`, `assets/` |
| C2 | Run Launch-Dev, check Vite log | `Local: http://localhost:3001` |
| C3 | Run launcher, check server log | `CodeNomad Server is ready at http://127.0.0.1:3001` |
| C4 | Run install, grep log | `OpenCode v<x.y.z> downloaded, checksum verified` |
| C5 | Run install, check log | Hashes match in output |
| C6 | Run from read-only dir | `Using fallback: C:\Users\xxx\NomadArch-Install` |
| C7 | Run install, check output | `UI build directory exists` + `Electron main.js exists` |
| C8 | `grep -n "electron-app" Launch-Dev-Windows.bat` | Only `packages\electron-app` or commented lines |
---
## Files Modified/Created
| File | Action |
|------|--------|
| Install-Windows.bat | Edit (C1, C4, C5, C6, C7) |
| Install-Mac.sh | Edit (C1, C4, C6, C7) |
| Install-Linux.sh | Edit (C1, C4, C6, C7) |
| Launch-Windows.bat | Edit (C1, C3) |
| Launch-Dev-Windows.bat | Edit (C1, C2, C3, C8) |
| Launch-Unix.sh | Edit (C1, C3) |
| Launch-Dev-Unix.sh | CREATE (C2) |
| packages/ui/vite.config.ts | Edit (C2) |

View File

@@ -1,306 +1,285 @@
#!/bin/bash
echo ""
echo " ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗"
echo " ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║"
echo " ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║"
echo " ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║"
echo " ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║"
echo " ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝"
echo ""
echo " INSTALLER - Enhanced with Auto-Dependency Resolution"
echo " ═════════════════════════════════════════════════════════════════════════════"
echo ""
# NomadArch Installer for Linux
# Version: 0.4.0
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
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
cd "$(dirname "$0")"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}
echo "[STEP 1/7] Detecting Linux Distribution..."
echo ""
echo "NomadArch Installer (Linux)"
echo "Version: 0.4.0"
echo ""
if [ -f /etc/os-release ]; then
log "Installer started"
echo "[STEP 1/9] OS and Architecture Detection"
OS_TYPE=$(uname -s)
ARCH_TYPE=$(uname -m)
log "OS: $OS_TYPE"
log "Architecture: $ARCH_TYPE"
if [[ "$OS_TYPE" != "Linux" ]]; then
echo -e "${RED}[ERROR]${NC} This installer is for Linux. Current OS: $OS_TYPE"
log "ERROR: Not Linux ($OS_TYPE)"
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
DISTRO=$ID
DISTRO_VERSION=$VERSION_ID
echo "[OK] Detected: $PRETTY_NAME"
else
echo "[WARN] Could not detect specific distribution"
DISTRO="unknown"
WARNINGS=$((WARNINGS + 1))
echo -e "${GREEN}[INFO]${NC} Distribution: ${PRETTY_NAME:-unknown}"
fi
echo ""
echo "[STEP 2/7] Checking System Requirements..."
echo ""
if ! command -v node &> /dev/null; then
echo "[ERROR] Node.js not found!"
echo ""
echo "NomadArch requires Node.js to run."
echo ""
echo "Install using your package manager:"
if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then
echo " sudo apt update && sudo apt install -y nodejs npm"
elif [ "$DISTRO" = "fedora" ]; then
echo " sudo dnf install -y nodejs npm"
elif [ "$DISTRO" = "arch" ] || [ "$DISTRO" = "manjaro" ]; then
echo " sudo pacman -S nodejs npm"
elif [ "$DISTRO" = "opensuse-leap" ] || [ "$DISTRO" = "opensuse-tumbleweed" ]; then
echo " sudo zypper install -y nodejs npm"
else
echo " Visit https://nodejs.org/ for installation instructions"
echo "[STEP 2/9] Checking write permissions"
mkdir -p "$BIN_DIR"
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"
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"
else
rm -f "$SCRIPT_DIR/.install-write-test"
echo -e "${GREEN}[OK]${NC} Write access OK"
fi
log "Install target: $TARGET_DIR"
echo ""
echo "Or install Node.js using NVM (Node Version Manager):"
echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash"
echo " source ~/.bashrc"
echo " nvm install 20"
echo ""
echo "[STEP 3/9] Ensuring system dependencies"
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
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")
command -v node >/dev/null 2>&1 || MISSING_PKGS+=("nodejs")
command -v npm >/dev/null 2>&1 || MISSING_PKGS+=("npm")
if [[ ${#MISSING_PKGS[@]} -gt 0 ]]; then
install_packages "$PACKAGE_MANAGER" "${MISSING_PKGS[@]}"
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)
echo "[OK] Node.js detected: $NODE_VERSION"
NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1 | sed 's/v//')
if [ "$NODE_MAJOR" -lt 18 ]; then
echo "[WARN] Node.js version is too old (found v$NODE_VERSION, required 18+)"
echo "[INFO] Please update Node.js"
WARNINGS=$((WARNINGS + 1))
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; then
echo "[ERROR] npm not found! This should come with Node.js."
echo "Please reinstall Node.js"
ERRORS=$((ERRORS + 1))
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 "[OK] npm detected: $NPM_VERSION"
echo -e "${GREEN}[OK]${NC} npm: $NPM_VERSION"
echo ""
echo "[STEP 3/7] Checking OpenCode CLI..."
echo ""
if command -v opencode &> /dev/null; then
echo "[OK] OpenCode is already installed globally"
OPENCODE_DONE=true
elif [ -f "bin/opencode" ]; then
echo "[OK] OpenCode binary found in bin/ folder"
OPENCODE_DONE=true
if command -v git >/dev/null 2>&1; then
echo -e "${GREEN}[OK]${NC} Git: $(git --version)"
else
OPENCODE_DONE=false
fi
if [ "$OPENCODE_DONE" = false ]; then
echo "[SETUP] OpenCode CLI not found. Installing..."
echo ""
echo "[INFO] Attempting to install OpenCode via npm..."
npm install -g opencode-ai@latest
if [ $? -eq 0 ]; then
echo "[SUCCESS] OpenCode installed successfully via npm"
if command -v opencode &> /dev/null; then
echo "[OK] OpenCode is now available in system PATH"
OPENCODE_DONE=true
fi
else
echo "[WARN] npm install failed, trying fallback method..."
echo ""
if [ ! -d "bin" ]; then
mkdir bin
fi
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
FILENAME="opencode-linux-x64.zip"
elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
FILENAME="opencode-linux-arm64.zip"
else
echo "[WARN] Unsupported architecture: $ARCH"
WARNINGS=$((WARNINGS + 1))
FILENAME=""
fi
if [ -n "$FILENAME" ]; then
echo "[SETUP] Downloading OpenCode from GitHub releases..."
curl -L -o "opencode.zip" "https://github.com/sst/opencode/releases/latest/download/$FILENAME"
if [ $? -ne 0 ]; then
echo "[ERROR] Failed to download OpenCode from GitHub!"
echo "[INFO] You can install OpenCode CLI manually from: https://opencode.ai/"
WARNINGS=$((WARNINGS + 1))
else
echo "[OK] Downloaded OpenCode ZIP"
echo "[SETUP] Extracting OpenCode binary..."
unzip -q "opencode.zip" -d "opencode-temp"
if [ -f "opencode-temp/opencode" ]; then
mv "opencode-temp/opencode" "bin/opencode"
chmod +x "bin/opencode"
echo "[OK] OpenCode binary placed in bin/ folder"
else
echo "[ERROR] opencode binary not found in extracted files!"
WARNINGS=$((WARNINGS + 1))
fi
rm -f "opencode.zip"
rm -rf "opencode-temp"
fi
fi
fi
echo -e "${YELLOW}[WARN]${NC} Git not found (optional)"
((WARNINGS++))
fi
echo ""
echo "[STEP 4/7] Installing NomadArch Dependencies..."
echo ""
if [ -d "node_modules" ]; then
echo "[INFO] node_modules found. Skipping dependency installation."
echo "[INFO] To force reinstall, delete node_modules and run again."
goto :BUILD_CHECK
fi
echo "[INFO] Installing root dependencies..."
npm install
if [ $? -ne 0 ]; then
echo "[ERROR] Failed to install root dependencies!"
ERRORS=$((ERRORS + 1))
fi
echo "[INFO] Installing package dependencies..."
if [ -d "packages/server" ]; then
echo "[INFO] Installing server dependencies..."
cd packages/server
npm install
if [ $? -ne 0 ]; then
echo "[WARN] Failed to install server dependencies!"
WARNINGS=$((WARNINGS + 1))
else
echo "[OK] Server dependencies installed"
fi
cd ../..
fi
if [ -d "packages/ui" ]; then
echo "[INFO] Installing UI dependencies..."
cd packages/ui
npm install
if [ $? -ne 0 ]; then
echo "[WARN] Failed to install UI dependencies!"
WARNINGS=$((WARNINGS + 1))
else
echo "[OK] UI dependencies installed"
fi
cd ../..
fi
if [ -d "packages/electron-app" ]; then
echo "[INFO] Installing Electron app dependencies..."
cd packages/electron-app
npm install
if [ $? -ne 0 ]; then
echo "[WARN] Failed to install Electron app dependencies!"
WARNINGS=$((WARNINGS + 1))
else
echo "[OK] Electron app dependencies installed"
fi
cd ../..
fi
echo ""
echo "[STEP 5/7] Setting Permissions..."
echo ""
chmod +x Launch-Unix.sh 2>/dev/null
chmod +x Install-Linux.sh 2>/dev/null
chmod +x Install-Mac.sh 2>/dev/null
echo "[OK] Scripts permissions set"
echo ""
echo "[STEP 6/7] Checking for Existing Build..."
echo ""
if [ -d "packages/ui/dist" ]; then
echo "[OK] UI build found. Skipping build step."
echo "[INFO] To rebuild, delete packages/ui/dist and run installer again."
goto :INSTALL_REPORT
fi
echo "[INFO] No UI build found. Building UI..."
echo ""
cd packages/ui
npm run build
if [ $? -ne 0 ]; then
echo "[WARN] Failed to build UI!"
WARNINGS=$((WARNINGS + 1))
echo "[INFO] You can build manually later by running: cd packages/ui && npm run build"
fi
cd ../..
echo ""
echo "[STEP 7/7] Testing Installation..."
echo ""
node --version >nul 2>&1
if [ $? -eq 0 ]; then
echo "[OK] Node.js is working"
else
echo "[FAIL] Node.js is not working correctly"
ERRORS=$((ERRORS + 1))
fi
npm --version >nul 2>&1
if [ $? -eq 0 ]; then
echo "[OK] npm is working"
else
echo "[FAIL] npm is not working correctly"
ERRORS=$((ERRORS + 1))
fi
if command -v opencode &> /dev/null; then
echo "[OK] OpenCode CLI is available"
elif [ -f "bin/opencode" ]; then
echo "[OK] OpenCode binary found in bin/ folder"
else
echo "[FAIL] OpenCode CLI not available"
WARNINGS=$((WARNINGS + 1))
fi
echo ""
echo "Installation Summary"
echo ""
if [ $ERRORS -gt 0 ]; then
echo ""
echo "════════════════════════════════════════════════════════════════════════════"
echo "[FAILED] Installation encountered $ERRORS error(s)!"
echo ""
echo "Please review error messages above and try again."
echo "For help, see: https://github.com/roman-ryzenadvanced/NomadArch-v1.0/issues"
echo "════════════════════════════════════════════════════════════════════════════"
echo ""
echo "[STEP 4/9] Installing npm dependencies"
cd "$SCRIPT_DIR"
log "Running npm install"
if ! npm install; then
echo -e "${RED}[ERROR]${NC} npm install failed"
log "ERROR: npm install failed"
exit 1
fi
echo ""
echo "════════════════════════════════════════════════════════════════════════════"
echo "[SUCCESS] Installation Complete!"
echo ""
echo -e "${GREEN}[OK]${NC} Dependencies installed"
if [ $WARNINGS -gt 0 ]; then
echo "[WARN] There were $WARNINGS warning(s) during installation."
echo "Review warnings above. Most warnings are non-critical."
echo ""
echo "[STEP 5/9] Fetching OpenCode binary"
mkdir -p "$BIN_DIR"
OPENCODE_VERSION=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
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"
if [[ -f "$BIN_DIR/opencode" ]]; then
echo -e "${GREEN}[OK]${NC} OpenCode binary already exists"
else
echo -e "${BLUE}[INFO]${NC} Downloading OpenCode v${OPENCODE_VERSION}"
curl -L -o "$BIN_DIR/opencode.tmp" "$OPENCODE_URL"
curl -L -o "$BIN_DIR/checksums.txt" "$CHECKSUM_URL"
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
mv "$BIN_DIR/opencode.tmp" "$BIN_DIR/opencode"
chmod +x "$BIN_DIR/opencode"
echo -e "${GREEN}[OK]${NC} OpenCode downloaded and verified"
else
echo -e "${RED}[ERROR]${NC} OpenCode checksum mismatch"
rm -f "$BIN_DIR/opencode.tmp"
exit 1
fi
fi
echo "You can now run NomadArch using:"
echo " ./Launch-Unix.sh"
echo ""
echo "For help and documentation, see: README.md"
echo "════════════════════════════════════════════════════════════════════════════"
echo "[STEP 6/9] Building UI assets"
if [[ -d "$SCRIPT_DIR/packages/ui/dist" ]]; then
echo -e "${GREEN}[OK]${NC} UI build already 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}[OK]${NC} UI assets built"
fi
echo ""
echo "[STEP 7/9] Post-install health check"
HEALTH_ERRORS=0
[[ -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))
if [[ $HEALTH_ERRORS -eq 0 ]]; then
echo -e "${GREEN}[OK]${NC} Health checks passed"
else
echo -e "${RED}[ERROR]${NC} Health checks failed ($HEALTH_ERRORS)"
ERRORS=$((ERRORS+HEALTH_ERRORS))
fi
echo ""
echo "[STEP 8/9] Installation Summary"
echo ""
echo " Install Dir: $TARGET_DIR"
echo " Architecture: $ARCH"
echo " Node.js: $NODE_VERSION"
echo " npm: $NPM_VERSION"
echo " Errors: $ERRORS"
echo " Warnings: $WARNINGS"
echo " Log File: $LOG_FILE"
echo ""
echo "[STEP 9/9] Next steps"
if [[ $ERRORS -gt 0 ]]; then
echo -e "${RED}[RESULT]${NC} Installation completed with errors"
echo "Review $LOG_FILE for details."
else
echo -e "${GREEN}[RESULT]${NC} Installation completed successfully"
echo "Run: ./Launch-Unix.sh"
fi
exit $ERRORS

View File

@@ -1,331 +1,221 @@
#!/bin/bash
echo ""
echo " ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗"
echo " ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║"
echo " ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║"
echo " ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║"
echo " ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║"
echo " ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝"
echo ""
echo " INSTALLER - macOS Enhanced with Auto-Dependency Resolution"
echo " ═══════════════════════════════════════════════════════════════════════════"
echo ""
# NomadArch Installer for macOS
# Version: 0.4.0
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
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
cd "$(dirname "$0")"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}
echo "[STEP 1/7] Checking macOS Version..."
echo ""
echo "NomadArch Installer (macOS)"
echo "Version: 0.4.0"
echo ""
if [ -f /System/Library/CoreServices/SystemVersion.plist ]; then
MAC_VERSION=$(defaults read /System/Library/CoreServices/SystemVersion.plist ProductVersion)
MAC_MAJOR=$(echo $MAC_VERSION | cut -d. -f1)
echo "[OK] macOS detected: $MAC_VERSION"
log "Installer started"
if [ "$MAC_MAJOR" -lt 11 ]; then
echo "[WARN] NomadArch requires macOS 11+ (Big Sur or later)"
echo "[INFO] Your version is $MAC_VERSION"
echo "[INFO] Please upgrade macOS to continue"
echo "[STEP 1/9] OS and Architecture Detection"
OS_TYPE=$(uname -s)
ARCH_TYPE=$(uname -m)
log "OS: $OS_TYPE"
log "Architecture: $ARCH_TYPE"
if [[ "$OS_TYPE" != "Darwin" ]]; then
echo -e "${RED}[ERROR]${NC} This installer is for macOS. Current OS: $OS_TYPE"
log "ERROR: Not macOS ($OS_TYPE)"
exit 1
fi
case "$ARCH_TYPE" in
arm64) ARCH="arm64" ;;
x86_64) ARCH="x64" ;;
*)
echo -e "${RED}[ERROR]${NC} Unsupported architecture: $ARCH_TYPE"
log "ERROR: Unsupported arch $ARCH_TYPE"
exit 1
;;
esac
echo -e "${GREEN}[OK]${NC} OS: macOS"
echo -e "${GREEN}[OK]${NC} Architecture: $ARCH_TYPE"
echo ""
echo "[STEP 2/9] Checking write permissions"
mkdir -p "$BIN_DIR"
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"
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"
else
echo "[WARN] Could not detect macOS version"
WARNINGS=$((WARNINGS + 1))
rm -f "$SCRIPT_DIR/.install-write-test"
echo -e "${GREEN}[OK]${NC} Write access OK"
fi
ARCH=$(uname -m)
if [ "$ARCH" = "arm64" ]; then
echo "[OK] Apple Silicon detected (M1/M2/M3 chip)"
elif [ "$ARCH" = "x86_64" ]; then
echo "[OK] Intel Mac detected"
else
echo "[WARN] Unknown architecture: $ARCH"
WARNINGS=$((WARNINGS + 1))
log "Install target: $TARGET_DIR"
echo ""
echo "[STEP 3/9] Ensuring system dependencies"
if ! command -v curl >/dev/null 2>&1; then
echo -e "${RED}[ERROR]${NC} curl is required but not available"
exit 1
fi
echo ""
echo "[STEP 2/7] Checking System Requirements..."
echo ""
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)"
fi
if ! command -v node &> /dev/null; then
echo "[ERROR] Node.js not found!"
echo ""
echo "NomadArch requires Node.js to run."
echo ""
echo "Install Node.js using one of these methods:"
echo ""
echo " 1. Homebrew (recommended):"
echo " brew install node"
echo ""
echo " 2. Download from official site:"
echo " Visit https://nodejs.org/"
echo " Download and install macOS installer"
echo ""
echo " 3. Using NVM (Node Version Manager):"
echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash"
echo " source ~/.zshrc (or ~/.bash_profile)"
echo " nvm install 20"
echo ""
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[@]}"
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)
echo "[OK] Node.js detected: $NODE_VERSION"
NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1 | sed 's/v//')
if [ "$NODE_MAJOR" -lt 18 ]; then
echo "[WARN] Node.js version is too old (found v$NODE_VERSION, required 18+)"
echo "[INFO] Please update Node.js: brew upgrade node"
WARNINGS=$((WARNINGS + 1))
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; then
echo "[ERROR] npm not found! This should come with Node.js."
echo "Please reinstall Node.js"
ERRORS=$((ERRORS + 1))
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 "[OK] npm detected: $NPM_VERSION"
echo -e "${GREEN}[OK]${NC} npm: $NPM_VERSION"
echo "[INFO] Checking Xcode Command Line Tools..."
if ! command -v xcode-select &> /dev/null; then
echo "[WARN] Xcode Command Line Tools not installed"
echo "[INFO] Required for building native Node.js modules"
echo ""
echo "Install by running:"
echo " xcode-select --install"
echo ""
echo "This will open a dialog to install the tools."
WARNINGS=$((WARNINGS + 1))
if command -v git >/dev/null 2>&1; then
echo -e "${GREEN}[OK]${NC} Git: $(git --version)"
else
XCODE_PATH=$(xcode-select -p)
echo "[OK] Xcode Command Line Tools detected: $XCODE_PATH"
echo -e "${YELLOW}[WARN]${NC} Git not found (optional)"
((WARNINGS++))
fi
echo ""
echo "[STEP 3/7] Checking OpenCode CLI..."
echo ""
if command -v opencode &> /dev/null; then
echo "[OK] OpenCode is already installed globally"
OPENCODE_DONE=true
elif [ -f "bin/opencode" ]; then
echo "[OK] OpenCode binary found in bin/ folder"
OPENCODE_DONE=true
else
OPENCODE_DONE=false
fi
if [ "$OPENCODE_DONE" = false ]; then
echo "[SETUP] OpenCode CLI not found. Installing..."
echo ""
echo "[INFO] Attempting to install OpenCode via npm..."
npm install -g opencode-ai@latest
if [ $? -eq 0 ]; then
echo "[SUCCESS] OpenCode installed successfully via npm"
if command -v opencode &> /dev/null; then
echo "[OK] OpenCode is now available in system PATH"
OPENCODE_DONE=true
fi
else
echo "[WARN] npm install failed, trying fallback method..."
echo ""
if [ ! -d "bin" ]; then
mkdir bin
fi
if [ "$ARCH" = "arm64" ]; then
FILENAME="opencode-darwin-arm64.zip"
elif [ "$ARCH" = "x86_64" ]; then
FILENAME="opencode-darwin-x64.zip"
else
echo "[WARN] Unsupported architecture: $ARCH"
WARNINGS=$((WARNINGS + 1))
FILENAME=""
fi
if [ -n "$FILENAME" ]; then
echo "[SETUP] Downloading OpenCode from GitHub releases..."
curl -L -o "opencode.zip" "https://github.com/sst/opencode/releases/latest/download/$FILENAME"
if [ $? -ne 0 ]; then
echo "[ERROR] Failed to download OpenCode from GitHub!"
echo "[INFO] You can install OpenCode CLI manually from: https://opencode.ai/"
WARNINGS=$((WARNINGS + 1))
else
echo "[OK] Downloaded OpenCode ZIP"
echo "[SETUP] Extracting OpenCode binary..."
unzip -q "opencode.zip" -d "opencode-temp"
if [ -f "opencode-temp/opencode" ]; then
mv "opencode-temp/opencode" "bin/opencode"
chmod +x "bin/opencode"
echo "[OK] OpenCode binary placed in bin/ folder"
else
echo "[ERROR] opencode binary not found in extracted files!"
WARNINGS=$((WARNINGS + 1))
fi
rm -f "opencode.zip"
rm -rf "opencode-temp"
fi
fi
fi
fi
echo ""
echo "[STEP 4/7] Installing NomadArch Dependencies..."
echo ""
if [ -d "node_modules" ]; then
echo "[INFO] node_modules found. Skipping dependency installation."
echo "[INFO] To force reinstall, delete node_modules and run again."
goto :BUILD_CHECK
fi
echo "[INFO] Installing root dependencies..."
npm install
if [ $? -ne 0 ]; then
echo "[ERROR] Failed to install root dependencies!"
ERRORS=$((ERRORS + 1))
fi
echo "[INFO] Installing package dependencies..."
if [ -d "packages/server" ]; then
echo "[INFO] Installing server dependencies..."
cd packages/server
npm install
if [ $? -ne 0 ]; then
echo "[WARN] Failed to install server dependencies!"
WARNINGS=$((WARNINGS + 1))
else
echo "[OK] Server dependencies installed"
fi
cd ../..
fi
if [ -d "packages/ui" ]; then
echo "[INFO] Installing UI dependencies..."
cd packages/ui
npm install
if [ $? -ne 0 ]; then
echo "[WARN] Failed to install UI dependencies!"
WARNINGS=$((WARNINGS + 1))
else
echo "[OK] UI dependencies installed"
fi
cd ../..
fi
if [ -d "packages/electron-app" ]; then
echo "[INFO] Installing Electron app dependencies..."
cd packages/electron-app
npm install
if [ $? -ne 0 ]; then
echo "[WARN] Failed to install Electron app dependencies!"
WARNINGS=$((WARNINGS + 1))
else
echo "[OK] Electron app dependencies installed"
fi
cd ../..
fi
echo ""
echo "[STEP 5/7] Setting Permissions..."
echo ""
chmod +x Launch-Unix.sh 2>/dev/null
chmod +x Install-Linux.sh 2>/dev/null
chmod +x Install-Mac.sh 2>/dev/null
echo "[OK] Scripts permissions set"
echo ""
echo "[STEP 6/7] Checking for Existing Build..."
echo ""
if [ -d "packages/ui/dist" ]; then
echo "[OK] UI build found. Skipping build step."
echo "[INFO] To rebuild, delete packages/ui/dist and run installer again."
goto :INSTALL_REPORT
fi
echo "[INFO] No UI build found. Building UI..."
echo ""
cd packages/ui
npm run build
if [ $? -ne 0 ]; then
echo "[WARN] Failed to build UI!"
WARNINGS=$((WARNINGS + 1))
echo "[INFO] You can build manually later by running: cd packages/ui && npm run build"
fi
cd ../..
echo ""
echo "[STEP 7/7] Testing Installation..."
echo ""
node --version >nul 2>&1
if [ $? -eq 0 ]; then
echo "[OK] Node.js is working"
else
echo "[FAIL] Node.js is not working correctly"
ERRORS=$((ERRORS + 1))
fi
npm --version >nul 2>&1
if [ $? -eq 0 ]; then
echo "[OK] npm is working"
else
echo "[FAIL] npm is not working correctly"
ERRORS=$((ERRORS + 1))
fi
if command -v opencode &> /dev/null; then
echo "[OK] OpenCode CLI is available"
elif [ -f "bin/opencode" ]; then
echo "[OK] OpenCode binary found in bin/ folder"
else
echo "[FAIL] OpenCode CLI not available"
WARNINGS=$((WARNINGS + 1))
fi
echo ""
echo "Installation Summary"
echo ""
if [ $ERRORS -gt 0 ]; then
echo ""
echo "════════════════════════════════════════════════════════════════════════════"
echo "[FAILED] Installation encountered $ERRORS error(s)!"
echo ""
echo "Please review error messages above and try again."
echo "For help, see: https://github.com/roman-ryzenadvanced/NomadArch-v1.0/issues"
echo "════════════════════════════════════════════════════════════════════════════"
echo ""
echo "[STEP 4/9] Installing npm dependencies"
cd "$SCRIPT_DIR"
log "Running npm install"
if ! npm install; then
echo -e "${RED}[ERROR]${NC} npm install failed"
log "ERROR: npm install failed"
exit 1
fi
echo ""
echo "════════════════════════════════════════════════════════════════════════════"
echo "[SUCCESS] Installation Complete!"
echo ""
echo -e "${GREEN}[OK]${NC} Dependencies installed"
if [ $WARNINGS -gt 0 ]; then
echo "[WARN] There were $WARNINGS warning(s) during installation."
echo "Review warnings above. Most warnings are non-critical."
echo ""
echo "[STEP 5/9] Fetching OpenCode binary"
mkdir -p "$BIN_DIR"
OPENCODE_VERSION=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
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"
if [[ -f "$BIN_DIR/opencode" ]]; then
echo -e "${GREEN}[OK]${NC} OpenCode binary already exists"
else
echo -e "${BLUE}[INFO]${NC} Downloading OpenCode v${OPENCODE_VERSION}"
curl -L -o "$BIN_DIR/opencode.tmp" "$OPENCODE_URL"
curl -L -o "$BIN_DIR/checksums.txt" "$CHECKSUM_URL"
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
mv "$BIN_DIR/opencode.tmp" "$BIN_DIR/opencode"
chmod +x "$BIN_DIR/opencode"
echo -e "${GREEN}[OK]${NC} OpenCode downloaded and verified"
else
echo -e "${RED}[ERROR]${NC} OpenCode checksum mismatch"
rm -f "$BIN_DIR/opencode.tmp"
exit 1
fi
fi
echo "You can now run NomadArch using:"
echo " ./Launch-Unix.sh"
echo ""
echo "For help and documentation, see: README.md"
echo "════════════════════════════════════════════════════════════════════════════"
echo "[STEP 6/9] Building UI assets"
if [[ -d "$SCRIPT_DIR/packages/ui/dist" ]]; then
echo -e "${GREEN}[OK]${NC} UI build already 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}[OK]${NC} UI assets built"
fi
echo ""
echo "[STEP 7/9] Post-install health check"
HEALTH_ERRORS=0
[[ -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))
if [[ $HEALTH_ERRORS -eq 0 ]]; then
echo -e "${GREEN}[OK]${NC} Health checks passed"
else
echo -e "${RED}[ERROR]${NC} Health checks failed ($HEALTH_ERRORS)"
ERRORS=$((ERRORS+HEALTH_ERRORS))
fi
echo ""
echo "[STEP 8/9] Installation Summary"
echo ""
echo " Install Dir: $TARGET_DIR"
echo " Architecture: $ARCH"
echo " Node.js: $NODE_VERSION"
echo " npm: $NPM_VERSION"
echo " Errors: $ERRORS"
echo " Warnings: $WARNINGS"
echo " Log File: $LOG_FILE"
echo ""
echo "[STEP 9/9] Next steps"
if [[ $ERRORS -gt 0 ]]; then
echo -e "${RED}[RESULT]${NC} Installation completed with errors"
echo "Review $LOG_FILE for details."
else
echo -e "${GREEN}[RESULT]${NC} Installation completed successfully"
echo "Run: ./Launch-Unix.sh"
fi
exit $ERRORS

View File

@@ -1,318 +1,253 @@
@echo off
title NomadArch Installer
color 0A
setlocal enabledelayedexpansion
title NomadArch Installer
echo.
echo ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗
echo ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║
echo ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║
echo ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║
echo ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║
echo ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
echo.
echo INSTALLER - Enhanced with Auto-Dependency Resolution
echo ═══════════════════════════════════════════════════════════════════════════════
echo NomadArch Installer (Windows)
echo Version: 0.4.0
echo.
set SCRIPT_DIR=%~dp0
set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
set TARGET_DIR=%SCRIPT_DIR%
set BIN_DIR=%TARGET_DIR%\bin
set LOG_FILE=%TARGET_DIR%\install.log
set TEMP_DIR=%TARGET_DIR%\.install-temp
set ERRORS=0
set WARNINGS=0
set NEEDS_FALLBACK=0
cd /d "%~dp0"
echo [%date% %time%] Installer started >> "%LOG_FILE%"
echo [STEP 1/9] OS and Architecture Detection
wmic os get osarchitecture | findstr /i "64-bit" >nul
if %ERRORLEVEL% equ 0 (
set ARCH=x64
) else (
set ARCH=x86
)
echo [OK] Architecture: %ARCH%
echo [STEP 1/6] Checking System Requirements...
echo.
echo [STEP 2/9] Checking write permissions
if not exist "%BIN_DIR%" mkdir "%BIN_DIR%"
if not exist "%TEMP_DIR%" mkdir "%TEMP_DIR%"
:: Check for Administrator privileges
net session >nul 2>&1
echo. > "%SCRIPT_DIR%\test-write.tmp" 2>nul
if %ERRORLEVEL% neq 0 (
echo [WARN] Not running as Administrator. Some operations may fail.
set /a WARNINGS+=1
echo.
echo [WARN] Cannot write to current directory: %SCRIPT_DIR%
set TARGET_DIR=%USERPROFILE%\NomadArch-Install
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"
echo [OK] Write permissions verified
)
echo.
echo [STEP 3/9] Ensuring system dependencies
set WINGET_AVAILABLE=0
where winget >nul 2>&1 && set WINGET_AVAILABLE=1
set CHOCO_AVAILABLE=0
where choco >nul 2>&1 && set CHOCO_AVAILABLE=1
set DOWNLOAD_CMD=
where curl >nul 2>&1
if %ERRORLEVEL% equ 0 (
set DOWNLOAD_CMD=curl
) else (
set DOWNLOAD_CMD=powershell
)
:: Check for Node.js
echo [INFO] Checking Node.js...
where node >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [ERROR] Node.js not found!
echo.
echo NomadArch requires Node.js to run.
echo.
echo Download from: https://nodejs.org/
echo Recommended: Node.js 18.x LTS or 20.x LTS
echo.
echo Opening download page...
start "" "https://nodejs.org/"
echo.
echo Please install Node.js and run this installer again.
echo.
pause
exit /b 1
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
) else (
echo [ERROR] No supported package manager found (winget/choco).
echo Please install Node.js LTS from https://nodejs.org/
set /a ERRORS+=1
goto :SUMMARY
)
)
:: Display Node.js version
for /f "tokens=*" %%i in ('node --version') do set NODE_VERSION=%%i
echo [OK] Node.js found: %NODE_VERSION%
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%
:: Check for npm
echo [INFO] Checking npm...
where npm >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [ERROR] npm not found!
echo.
echo npm is required for dependency management.
echo.
pause
exit /b 1
echo [ERROR] npm not found after Node.js install.
set /a ERRORS+=1
goto :SUMMARY
)
for /f "tokens=*" %%i in ('npm --version') do set NPM_VERSION=%%i
echo [OK] npm found: %NPM_VERSION%
echo [OK] npm: %NPM_VERSION%
echo.
echo [STEP 2/6] Checking OpenCode CLI...
echo.
:: Check if opencode is already installed globally
where opencode >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [OK] OpenCode is already installed globally
goto :OPENCODE_DONE
)
:: Check if opencode exists in bin/ folder
if exist "bin\opencode.exe" (
echo [OK] OpenCode binary found in bin/ folder
goto :OPENCODE_DONE
)
:: Install OpenCode CLI
echo [SETUP] OpenCode CLI not found. Installing...
echo.
echo [INFO] Attempting to install OpenCode via npm...
call npm install -g opencode-ai@latest
if %ERRORLEVEL% equ 0 (
echo [SUCCESS] OpenCode installed successfully via npm
where opencode >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [OK] OpenCode is now available in system PATH
goto :OPENCODE_DONE
)
)
echo [WARN] npm install failed or not in PATH, trying fallback method...
echo.
:: Fallback: Download from GitHub releases
echo [SETUP] Downloading OpenCode from GitHub releases...
echo.
:: Download Windows x64 ZIP
curl -L -o "opencode-windows-x64.zip" "https://github.com/sst/opencode/releases/latest/download/opencode-windows-x64.zip"
where git >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [ERROR] Failed to download OpenCode from GitHub!
set /a ERRORS+=1
goto :INSTALL_DEPS
)
echo [OK] Downloaded OpenCode ZIP
echo [SETUP] Extracting OpenCode binary...
:: Create bin directory if not exists
if not exist "bin" mkdir bin
:: Extract using PowerShell
powershell -Command "Expand-Archive -Path 'opencode-windows-x64.zip' -DestinationPath 'opencode-temp' -Force"
if %ERRORLEVEL% neq 0 (
echo [ERROR] Failed to extract OpenCode!
set /a ERRORS+=1
goto :CLEANUP
)
:: Move opencode.exe to bin/ folder
if exist "opencode-temp\opencode.exe" (
move /Y "opencode-temp\opencode.exe" "bin\opencode.exe" >nul
echo [OK] OpenCode binary placed in bin/ folder
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 [ERROR] opencode.exe not found in extracted files!
set /a ERRORS+=1
)
:CLEANUP
if exist "opencode-windows-x64.zip" del "opencode-windows-x64.zip"
if exist "opencode-temp" rmdir /s /q "opencode-temp"
:OPENCODE_DONE
echo.
echo [STEP 3/6] Installing NomadArch Dependencies...
echo.
:: Check if node_modules exists
if exist "node_modules" (
echo [INFO] node_modules found. Skipping dependency installation.
echo [INFO] To force reinstall, delete node_modules and run again.
goto :BUILD_CHECK
)
echo [INFO] Installing root dependencies...
call npm install
if %ERRORLEVEL% neq 0 (
echo [ERROR] Failed to install root dependencies!
set /a ERRORS+=1
goto :INSTALL_REPORT
)
echo [OK] Root dependencies installed
echo.
echo [INFO] Installing package dependencies...
:: Install server dependencies
if exist "packages\server" (
echo [INFO] Installing server dependencies...
cd packages\server
call npm install
if %ERRORLEVEL% neq 0 (
echo [WARN] Failed to install server dependencies!
echo [WARN] Git not installed (optional). Continue.
set /a WARNINGS+=1
) else (
echo [OK] Server dependencies installed
)
cd ..\..
) else (
for /f "tokens=*" %%i in ('git --version') do set GIT_VERSION=%%i
echo [OK] Git: %GIT_VERSION%
)
:: Install UI dependencies
if exist "packages\ui" (
echo [INFO] Installing UI dependencies...
cd packages\ui
echo.
echo [STEP 4/9] Installing npm dependencies
cd /d "%SCRIPT_DIR%"
echo [%date% %time%] Running npm install >> "%LOG_FILE%"
call npm install
if %ERRORLEVEL% neq 0 (
echo [WARN] Failed to install UI dependencies!
set /a WARNINGS+=1
echo [ERROR] npm install failed!
echo [%date% %time%] ERROR: npm install failed >> "%LOG_FILE%"
set /a ERRORS+=1
goto :SUMMARY
)
echo [OK] Dependencies installed
echo.
echo [STEP 5/9] Fetching OpenCode binary
if not exist "%BIN_DIR%" mkdir "%BIN_DIR%"
for /f "delims=" %%v in ('powershell -NoProfile -Command "(Invoke-WebRequest -UseBasicParsing https://api.github.com/repos/sst/opencode/releases/latest).Content ^| Select-String -Pattern '""tag_name""' ^| ForEach-Object { $_.Line.Split(''\"'')[3] }"') do (
set 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%"
) else (
echo [OK] UI dependencies installed
)
cd ..\..
)
:: Install Electron app dependencies
if exist "packages\electron-app" (
echo [INFO] Installing Electron app dependencies...
cd packages\electron-app
call npm install
if %ERRORLEVEL% neq 0 (
echo [WARN] Failed to install Electron app dependencies!
set /a WARNINGS+=1
echo [INFO] Downloading OpenCode v!OPENCODE_VERSION!...
if "%DOWNLOAD_CMD%"=="curl" (
curl -L -o "%BIN_DIR%\opencode.exe.tmp" "!OPENCODE_URL!"
curl -L -o "%BIN_DIR%\checksums.txt" "!CHECKSUM_URL!"
) else (
echo [OK] Electron app dependencies installed
)
cd ..\..
powershell -NoProfile -Command "Invoke-WebRequest -Uri '%OPENCODE_URL%' -OutFile '%BIN_DIR%\\opencode.exe.tmp'"
powershell -NoProfile -Command "Invoke-WebRequest -Uri '%CHECKSUM_URL%' -OutFile '%BIN_DIR%\\checksums.txt'"
)
:BUILD_CHECK
echo.
echo [STEP 4/6] Checking for Existing Build...
echo.
if exist "packages\ui\dist" (
echo [OK] UI build found. Skipping build step.
echo [INFO] To rebuild, delete packages\ui\dist and run installer again.
goto :INSTALL_REPORT
set EXPECTED_HASH=
for /f "tokens=1,2" %%h in ('type "%BIN_DIR%\checksums.txt" ^| findstr /i "opencode-windows-%ARCH%"') do (
set EXPECTED_HASH=%%h
)
echo [INFO] No UI build found. Building UI...
echo.
set ACTUAL_HASH=
for /f "skip=1 tokens=*" %%h in ('certutil -hashfile "%BIN_DIR%\opencode.exe.tmp" SHA256 ^| findstr /v "CertUtil" ^| findstr /v "hash of"') do (
set ACTUAL_HASH=%%h
goto :hash_found
)
:hash_found
:: Build UI
cd packages\ui
if "!ACTUAL_HASH!"=="!EXPECTED_HASH!" (
move /Y "%BIN_DIR%\opencode.exe.tmp" "%BIN_DIR%\opencode.exe"
echo [OK] OpenCode downloaded and verified
) else (
echo [ERROR] OpenCode checksum mismatch!
del "%BIN_DIR%\opencode.exe.tmp"
set /a ERRORS+=1
)
)
echo.
echo [STEP 6/9] 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
if %ERRORLEVEL% neq 0 (
echo [WARN] Failed to build UI!
set /a WARNINGS+=1
echo [INFO] You can build manually later by running: cd packages\ui ^&^& npm run build
)
cd ..\..
:INSTALL_REPORT
echo.
echo ═══════════════════════════════════════════════════════════════════════════════
echo INSTALLATION COMPLETE
echo ═══════════════════════════════════════════════════════════════════════════════
echo.
echo Summary:
echo.
if %ERRORS% equ 0 (
echo ✓ No errors encountered
) else (
echo%ERRORS% error(s) encountered
)
echo.
if %WARNINGS% equ 0 (
echo ✓ No warnings
) else (
echo%WARNINGS% warning(s) encountered
)
echo.
echo [STEP 5/6] Testing Installation...
echo.
:: Test node command
node --version >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [OK] Node.js is working
) else (
echo [FAIL] Node.js is not working correctly
echo [ERROR] UI build failed!
popd
set /a ERRORS+=1
goto :SUMMARY
)
popd
echo [OK] UI assets built successfully
)
:: Test npm command
npm --version >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [OK] npm is working
echo.
echo [STEP 7/9] Post-install health check
set HEALTH_ERRORS=0
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
if %HEALTH_ERRORS% equ 0 (
echo [OK] Health checks passed
) else (
echo [FAIL] npm is not working correctly
set /a ERRORS+=1
)
:: Test opencode command
where opencode >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [OK] OpenCode CLI is available
) else (
if exist "bin\opencode.exe" (
echo [OK] OpenCode binary found in bin/ folder
) else (
echo [FAIL] OpenCode CLI not available
set /a WARNINGS+=1
)
echo [ERROR] Health checks failed (%HEALTH_ERRORS%)
set /a ERRORS+=%HEALTH_ERRORS%
)
echo.
echo [STEP 6/6] Next Steps...
echo [STEP 8/9] Installation Summary
echo.
echo To start NomadArch:
echo 1. Double-click and run: Launch-Windows.bat
echo OR
echo 2. Run from command line: npm run dev:electron
echo.
echo For development mode:
echo Run: Launch-Dev-Windows.bat
echo Install Dir: %TARGET_DIR%
echo Architecture: %ARCH%
echo Node.js: %NODE_VERSION%
echo npm: %NPM_VERSION%
echo Errors: %ERRORS%
echo Warnings: %WARNINGS%
echo Log File: %LOG_FILE%
echo.
echo [STEP 9/9] Next steps
:SUMMARY
if %ERRORS% gtr 0 (
echo ⚠ INSTALLATION HAD ERRORS!
echo Please review the messages above and fix any issues.
echo [RESULT] Installation completed with errors.
echo Review the log: %LOG_FILE%
echo.
pause
exit /b 1
echo If Node.js was just installed, open a new terminal and run this installer again.
) else (
echo Installation completed successfully!
echo [RESULT] Installation completed successfully.
echo Run Launch-Windows.bat to start the application.
)
echo.
echo Press any key to exit...
pause >nul
exit /b 0
)
exit /b %ERRORS%

120
Launch-Dev-Unix.sh Normal file
View File

@@ -0,0 +1,120 @@
#!/bin/bash
# NomadArch Development Launcher for macOS and Linux
# Version: 0.4.0
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ERRORS=0
WARNINGS=0
AUTO_FIXED=0
echo ""
echo "NomadArch Development Launcher (macOS/Linux)"
echo "Version: 0.4.0"
echo ""
echo "[PREFLIGHT 1/6] Checking Dependencies..."
if ! command -v node &> /dev/null; then
echo -e "${YELLOW}[WARN]${NC} Node.js not found. Running installer..."
if [[ "$OSTYPE" == "darwin"* ]]; then
bash "$SCRIPT_DIR/Install-Mac.sh"
else
bash "$SCRIPT_DIR/Install-Linux.sh"
fi
echo -e "${BLUE}[INFO]${NC} If Node.js was installed, open a new terminal and run Launch-Dev-Unix.sh again."
exit 1
fi
NODE_VERSION=$(node --version)
echo -e "${GREEN}[OK]${NC} Node.js: $NODE_VERSION"
if ! command -v npm &> /dev/null; then
echo -e "${RED}[ERROR]${NC} npm not found!"
exit 1
fi
NPM_VERSION=$(npm --version)
echo -e "${GREEN}[OK]${NC} npm: $NPM_VERSION"
echo ""
echo "[PREFLIGHT 2/6] Installing dependencies if needed..."
if [[ ! -d "node_modules" ]]; then
echo -e "${YELLOW}[INFO]${NC} Dependencies not installed. Installing now..."
npm install
echo -e "${GREEN}[OK]${NC} Dependencies installed (auto-fix)"
((AUTO_FIXED++))
fi
echo ""
echo "[PREFLIGHT 3/6] Finding Available Ports..."
DEFAULT_SERVER_PORT=3001
DEFAULT_UI_PORT=3000
SERVER_PORT=$DEFAULT_SERVER_PORT
UI_PORT=$DEFAULT_UI_PORT
for port in {3001..3050}; do
if ! lsof -i :$port -sTCP:LISTEN -t > /dev/null 2>&1; then
SERVER_PORT=$port
break
fi
done
for port in {3000..3050}; do
if ! lsof -i :$port -sTCP:LISTEN -t > /dev/null 2>&1; then
UI_PORT=$port
break
fi
done
echo -e "${GREEN}[OK]${NC} Server port: $SERVER_PORT"
echo -e "${GREEN}[OK]${NC} UI port: $UI_PORT"
echo ""
echo "[PREFLIGHT 4/6] Launch Summary"
echo -e "${BLUE}[STATUS]${NC}"
echo ""
echo " Node.js: $NODE_VERSION"
echo " npm: $NPM_VERSION"
echo " Auto-fixes applied: $AUTO_FIXED"
echo " Warnings: $WARNINGS"
echo " Errors: $ERRORS"
echo " Server Port: $SERVER_PORT"
echo " UI Port: $UI_PORT"
echo ""
echo ""
echo "[PREFLIGHT 5/6] Starting services..."
echo ""
export CLI_PORT=$SERVER_PORT
export VITE_PORT=$UI_PORT
echo -e "${GREEN}[INFO]${NC} Starting backend server..."
nohup bash -c "cd '$SCRIPT_DIR/packages/server' && npm run dev" >/dev/null 2>&1 &
sleep 2
echo -e "${GREEN}[INFO]${NC} Starting UI server..."
nohup bash -c "cd '$SCRIPT_DIR/packages/ui' && npm run dev -- --port $UI_PORT" >/dev/null 2>&1 &
sleep 2
echo -e "${GREEN}[INFO]${NC} Starting Electron app..."
npm run dev:electron
echo ""
echo "[PREFLIGHT 6/6] Done."

View File

@@ -1,31 +1,29 @@
@echo off
title NomadArch Development Launcher
color 0B
setlocal enabledelayedexpansion
title NomadArch Development Launcher
color 0B
echo.
echo ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗
echo ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║
echo ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║
echo ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║
echo ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║
echo ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
echo.
echo DEVELOPMENT MODE - Separate Server & UI Terminals
echo ═════════════════════════════════════════════════════════════════════════════
echo NomadArch Development Launcher (Windows)
echo Version: 0.4.0
echo.
cd /d "%~dp0"
set SCRIPT_DIR=%~dp0
set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
cd /d "%SCRIPT_DIR%"
echo [STEP 1/4] Checking Dependencies...
echo.
set ERRORS=0
set WARNINGS=0
set AUTO_FIXED=0
echo [PREFLIGHT 1/7] Checking Dependencies...
where node >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [ERROR] Node.js not found!
echo.
echo Please install Node.js first: https://nodejs.org/
echo.
echo [WARN] Node.js not found. Running installer...
call "%SCRIPT_DIR%\Install-Windows.bat"
echo [INFO] If Node.js was installed, open a new terminal and run Launch-Dev-Windows.bat again.
pause
exit /b 1
)
@@ -36,7 +34,6 @@ echo [OK] Node.js: %NODE_VERSION%
where npm >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [ERROR] npm not found!
echo.
pause
exit /b 1
)
@@ -45,81 +42,141 @@ for /f "tokens=*" %%i in ('npm --version') do set NPM_VERSION=%%i
echo [OK] npm: %NPM_VERSION%
echo.
echo [STEP 2/4] Checking for OpenCode CLI...
echo.
echo [PREFLIGHT 2/7] Checking for OpenCode CLI...
where opencode >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [OK] OpenCode is available in PATH
echo [OK] OpenCode CLI available in PATH
) else (
if exist "bin\opencode.exe" (
echo [OK] OpenCode binary found in bin/ folder
echo [OK] OpenCode binary found in bin/
) else (
echo [WARN] OpenCode CLI not found
echo [INFO] Run Install-Windows.bat to install OpenCode
echo [INFO] Run Install-Windows.bat to set up OpenCode
set /a WARNINGS+=1
)
)
echo.
echo [STEP 3/4] Checking Port Availability...
echo.
echo [PREFLIGHT 3/7] Checking Dependencies...
set SERVER_PORT=3001
set UI_PORT=3000
netstat -ano | findstr ":%SERVER_PORT%" | findstr "LISTENING" >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [WARN] Port %SERVER_PORT% is already in use
echo [INFO] Another NomadArch instance may be running
echo [INFO] To find process: netstat -ano | findstr ":%SERVER_PORT%"
echo [INFO] To kill it: taskkill /F /PID ^<PID^>
if not exist "node_modules" (
echo [INFO] Dependencies not installed. Installing now...
call npm install
if %ERRORLEVEL% neq 0 (
echo [ERROR] Dependency installation failed!
pause
exit /b 1
)
echo [OK] Dependencies installed (auto-fix)
set /a AUTO_FIXED+=1
) else (
echo [OK] Port %SERVER_PORT% is available
)
netstat -ano | findstr ":%UI_PORT%" | findstr "LISTENING" >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [WARN] Port %UI_PORT% is already in use
echo [INFO] To find process: netstat -ano | findstr ":%UI_PORT%"
echo [INFO] To kill it: taskkill /F /PID ^<PID^>
) else (
echo [OK] Port %UI_PORT% is available
echo [OK] Dependencies found
)
echo.
echo [STEP 4/4] Starting NomadArch in Development Mode...
echo.
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
echo.
echo [INFO] Press any key to start...
pause >nul
echo [PREFLIGHT 4/7] Finding Available Ports...
set DEFAULT_SERVER_PORT=3001
set DEFAULT_UI_PORT=3000
set SERVER_PORT=%DEFAULT_SERVER_PORT%
set UI_PORT=%DEFAULT_UI_PORT%
for /l %%p in (%DEFAULT_SERVER_PORT%,1,3050) do (
netstat -ano | findstr ":%%p " | findstr "LISTENING" >nul
if !ERRORLEVEL! neq 0 (
set SERVER_PORT=%%p
goto :server_port_found
)
)
:server_port_found
for /l %%p in (%DEFAULT_UI_PORT%,1,3050) do (
netstat -ano | findstr ":%%p " | findstr "LISTENING" >nul
if !ERRORLEVEL! neq 0 (
set UI_PORT=%%p
goto :ui_port_found
)
)
:ui_port_found
echo [OK] Server port: !SERVER_PORT!
echo [OK] UI port: !UI_PORT!
echo.
echo [INFO] Starting Backend Server...
start "NomadArch Server" cmd /k "cd /d \"%~dp0packages\server\" && npm run dev"
echo [PREFLIGHT 5/7] Final Checks...
echo [INFO] Starting Frontend UI...
start "NomadArch UI" cmd /k "cd /d \"%~dp0packages\ui\" && npm run dev"
if not exist "packages\ui\dist\index.html" (
echo [WARN] UI build directory not found
echo [INFO] Running UI build...
pushd packages\ui
call npm run build
if %ERRORLEVEL% neq 0 (
echo [ERROR] UI build failed!
popd
set /a ERRORS+=1
goto :launch_check
)
popd
echo [OK] UI build completed (auto-fix)
set /a AUTO_FIXED+=1
)
echo [INFO] Starting Electron App...
if not exist "packages\electron-app\dist\main\main.js" (
echo [WARN] Electron build incomplete
echo [INFO] Running full build...
call npm run build
if %ERRORLEVEL% neq 0 (
echo [ERROR] Full build failed!
set /a ERRORS+=1
goto :launch_check
)
echo [OK] Full build completed (auto-fix)
set /a AUTO_FIXED+=1
)
echo.
echo [PREFLIGHT 6/7] Launch Summary
echo [STATUS]
echo.
echo Node.js: %NODE_VERSION%
echo npm: %NPM_VERSION%
echo Auto-fixes applied: !AUTO_FIXED!
echo Warnings: %WARNINGS%
echo Errors: %ERRORS%
echo Server Port: !SERVER_PORT!
echo UI Port: !UI_PORT!
echo.
if %ERRORS% gtr 0 (
echo [RESULT] Cannot start due to errors!
pause
exit /b 1
)
echo.
echo [PREFLIGHT 7/7] Starting NomadArch in Development Mode...
echo [INFO] Server: http://localhost:!SERVER_PORT!
echo [INFO] UI: http://localhost:!UI_PORT!
echo.
start "NomadArch Server" cmd /k "cd /d \"%~dp0packages\server\" && set CLI_PORT=!SERVER_PORT! && npm run dev"
timeout /t 3 /nobreak >nul
start "NomadArch UI" cmd /k "cd /d \"%~dp0packages\ui\" && set VITE_PORT=!UI_PORT! && npm run dev -- --port !UI_PORT!"
timeout /t 3 /nobreak >nul
start "NomadArch Electron" cmd /k "cd /d \"%~dp0packages\electron-app\" && npm run dev"
echo.
echo [OK] All services started!
echo.
echo Press any key to stop all services (Ctrl+C in each window also works)...
echo [OK] All services started.
echo Press any key to stop all services...
pause >nul
echo.
echo [INFO] Stopping all services...
taskkill /F /FI "WINDOWTITLE eq NomadArch*" >nul 2>&1
taskkill /F /FI "WINDOWTITLE eq NomadArch Server*" >nul 2>&1
taskkill /F /FI "WINDOWTITLE eq NomadArch UI*" >nul 2>&1
taskkill /F /FI "WINDOWTITLE eq NomadArch Electron*" >nul 2>&1
echo [OK] All services stopped.
echo.
:launch_check
pause
exit /b %ERRORS%

View File

@@ -1,133 +1,170 @@
#!/bin/bash
echo ""
echo " ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗"
echo " ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║"
echo " ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║"
echo " ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║"
echo " ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║"
echo " ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝"
echo ""
echo " LAUNCHER - Linux/macOS"
echo " ═════════════════════════════════════════════════════════════════════════════"
echo ""
# NomadArch Launcher for macOS and Linux
# Version: 0.4.0
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ERRORS=0
WARNINGS=0
AUTO_FIXED=0
cd "$(dirname "$0")"
echo "[STEP 1/5] Checking Dependencies..."
echo ""
echo "NomadArch Launcher (macOS/Linux)"
echo "Version: 0.4.0"
echo ""
echo "[PREFLIGHT 1/7] Checking Dependencies..."
if ! command -v node &> /dev/null; then
echo "[ERROR] Node.js not found!"
echo ""
echo "Please install Node.js first: https://nodejs.org/"
echo "Then run: ./Install-Linux.sh (or ./Install-Mac.sh on macOS)"
echo ""
echo -e "${YELLOW}[WARN]${NC} Node.js not found. Running installer..."
if [[ "$OSTYPE" == "darwin"* ]]; then
bash "$SCRIPT_DIR/Install-Mac.sh"
else
bash "$SCRIPT_DIR/Install-Linux.sh"
fi
echo -e "${BLUE}[INFO]${NC} If Node.js was installed, open a new terminal and run Launch-Unix.sh again."
exit 1
fi
NODE_VERSION=$(node --version)
echo "[OK] Node.js: $NODE_VERSION"
echo -e "${GREEN}[OK]${NC} Node.js: $NODE_VERSION"
if ! command -v npm &> /dev/null; then
echo "[ERROR] npm not found!"
echo ""
echo -e "${RED}[ERROR]${NC} npm not found!"
exit 1
fi
NPM_VERSION=$(npm --version)
echo "[OK] npm: $NPM_VERSION"
echo -e "${GREEN}[OK]${NC} npm: $NPM_VERSION"
echo ""
echo "[STEP 2/5] Checking for OpenCode CLI..."
echo ""
echo "[PREFLIGHT 2/7] Checking for OpenCode CLI..."
if command -v opencode &> /dev/null; then
echo "[OK] OpenCode is available in PATH"
elif [ -f "bin/opencode" ]; then
echo "[OK] OpenCode binary found in bin/ folder"
echo -e "${GREEN}[OK]${NC} OpenCode CLI available in PATH"
elif [[ -f "$SCRIPT_DIR/bin/opencode" ]]; then
echo -e "${GREEN}[OK]${NC} OpenCode binary found in bin/"
else
echo "[WARN] OpenCode CLI not found"
echo "[INFO] Run ./Install-Linux.sh (or ./Install-Mac.sh on macOS) to install OpenCode"
WARNINGS=$((WARNINGS + 1))
echo -e "${YELLOW}[WARN]${NC} OpenCode CLI not found"
echo "[INFO] Run Install-*.sh to set up OpenCode"
((WARNINGS++))
fi
echo ""
echo "[STEP 3/5] Checking for Existing Build..."
echo ""
echo "[PREFLIGHT 3/7] Checking Dependencies..."
if [ -d "packages/ui/dist" ]; then
echo "[OK] UI build found"
else
echo "[WARN] No UI build found. Building now..."
echo ""
cd packages/ui
npm run build
if [ $? -ne 0 ]; then
echo "[ERROR] UI build failed!"
ERRORS=$((ERRORS + 1))
else
echo "[OK] UI build completed"
if [[ ! -d "node_modules" ]]; then
echo -e "${YELLOW}[INFO]${NC} Dependencies not installed. Installing now..."
if ! npm install; then
echo -e "${RED}[ERROR]${NC} Dependency installation failed!"
exit 1
fi
cd ../..
echo -e "${GREEN}[OK]${NC} Dependencies installed (auto-fix)"
((AUTO_FIXED++))
else
echo -e "${GREEN}[OK]${NC} Dependencies found"
fi
echo ""
echo "[STEP 4/5] Checking Port Availability..."
echo "[PREFLIGHT 4/7] Finding Available Port..."
DEFAULT_SERVER_PORT=3001
DEFAULT_UI_PORT=3000
SERVER_PORT=$DEFAULT_SERVER_PORT
UI_PORT=$DEFAULT_UI_PORT
for port in {3001..3050}; do
if ! lsof -i :$port -sTCP:LISTEN -t > /dev/null 2>&1; then
SERVER_PORT=$port
break
fi
done
echo -e "${GREEN}[OK]${NC} Server port: $SERVER_PORT"
echo ""
echo "[PREFLIGHT 5/7] Final Checks..."
SERVER_PORT=3001
UI_PORT=3000
if lsof -Pi :$SERVER_PORT -sTCP:LISTEN -t >/dev/null 2>&1; then
echo "[WARN] Port $SERVER_PORT is already in use"
echo "[INFO] Another NomadArch instance or app may be running"
echo "[INFO] To find the process: lsof -i :$SERVER_PORT"
echo "[INFO] To kill it: kill -9 \$(lsof -t -i:$SERVER_PORT)"
WARNINGS=$((WARNINGS + 1))
if [[ ! -d "packages/ui/dist" ]]; then
echo -e "${YELLOW}[WARN]${NC} UI build directory not found"
echo -e "${YELLOW}[INFO]${NC} Running UI build..."
pushd packages/ui >/dev/null
if ! npm run build; then
echo -e "${RED}[ERROR]${NC} UI build failed!"
popd >/dev/null
((ERRORS++))
else
echo "[OK] Port $SERVER_PORT is available"
popd >/dev/null
echo -e "${GREEN}[OK]${NC} UI build completed (auto-fix)"
((AUTO_FIXED++))
fi
else
echo -e "${GREEN}[OK]${NC} UI build directory exists"
fi
if lsof -Pi :$UI_PORT -sTCP:LISTEN -t >/dev/null 2>&1; then
echo "[WARN] Port $UI_PORT is already in use"
echo "[INFO] To find the process: lsof -i :$UI_PORT"
echo "[INFO] To kill it: kill -9 \$(lsof -t -i:$UI_PORT)"
WARNINGS=$((WARNINGS + 1))
if [[ ! -f "packages/electron-app/dist/main/main.js" ]]; then
echo -e "${YELLOW}[WARN]${NC} Electron build incomplete"
echo -e "${YELLOW}[INFO]${NC} Running full build..."
if ! npm run build; then
echo -e "${RED}[ERROR]${NC} Full build failed!"
((ERRORS++))
else
echo "[OK] Port $UI_PORT is available"
echo -e "${GREEN}[OK]${NC} Full build completed (auto-fix)"
((AUTO_FIXED++))
fi
else
echo -e "${GREEN}[OK]${NC} Electron build exists"
fi
echo ""
echo "[STEP 5/5] Starting NomadArch..."
echo "[PREFLIGHT 6/7] Launch Summary"
echo -e "${BLUE}[STATUS]${NC}"
echo ""
echo " Node.js: $NODE_VERSION"
echo " npm: $NPM_VERSION"
echo " Auto-fixes applied: $AUTO_FIXED"
echo " Warnings: $WARNINGS"
echo " Errors: $ERRORS"
echo " Server Port: $SERVER_PORT"
echo ""
if [ $ERRORS -gt 0 ]; then
echo "[ERROR] Cannot start due to errors!"
echo ""
if [[ $ERRORS -gt 0 ]]; then
echo -e "${RED}[RESULT]${NC} Cannot start due to errors!"
exit 1
fi
echo "[INFO] Starting NomadArch..."
echo "[INFO] Server will run on http://localhost:$SERVER_PORT"
echo "[INFO] Press Ctrl+C to stop"
echo -e "${GREEN}[INFO]${NC} Starting NomadArch..."
echo -e "${GREEN}[INFO]${NC} Server will run on http://localhost:$SERVER_PORT"
echo -e "${YELLOW}[INFO]${NC} Press Ctrl+C to stop"
echo ""
SERVER_URL="http://localhost:$SERVER_PORT"
if [[ "$OSTYPE" == "darwin"* ]]; then
open "$SERVER_URL" 2>/dev/null || true
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
xdg-open "$SERVER_URL" 2>/dev/null || true
fi
export CLI_PORT=$SERVER_PORT
npm run dev:electron
if [ $? -ne 0 ]; then
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 (see warnings above)"
echo " 4. Check the error message above for details"
echo ""
echo "To reinstall everything: ./Install-Linux.sh (or ./Install-Mac.sh on macOS)"
EXIT_CODE=$?
if [[ $EXIT_CODE -ne 0 ]]; then
echo ""
echo -e "${RED}[ERROR]${NC} NomadArch exited with an error!"
fi
exit $EXIT_CODE

View File

@@ -1,33 +1,26 @@
@echo off
title NomadArch Launcher (Production Mode)
color 0A
setlocal enabledelayedexpansion
title NomadArch Launcher (Production Mode)
color 0A
echo.
echo ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗
echo ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║
echo ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║
echo ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║
echo ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║
echo ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
echo.
echo PRODUCTION LAUNCHER - Using Pre-Built Enhanced UI
echo ═════════════════════════════════════════════════════════════════════════════
echo.
echo NomadArch Launcher (Windows, Production Mode)
echo Version: 0.4.0
echo Features: SMART FIX / APEX / SHIELD / MULTIX MODE
echo.
cd /d "%~dp0"
set SCRIPT_DIR=%~dp0
set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
cd /d "%SCRIPT_DIR%"
echo [STEP 1/3] Checking Dependencies...
echo.
where node >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [ERROR] Node.js not found!
echo.
echo Please install Node.js first: https://nodejs.org/
echo.
echo [WARN] Node.js not found. Running installer...
call "%SCRIPT_DIR%\Install-Windows.bat"
echo [INFO] If Node.js was installed, open a new terminal and run Launch-Windows-Prod.bat again.
pause
exit /b 1
)
@@ -37,26 +30,22 @@ echo [OK] Node.js: %NODE_VERSION%
echo.
echo [STEP 2/3] Checking Pre-Built UI...
echo.
if exist "packages\electron-app\dist\renderer\assets\main-B67Oskqu.js" (
echo [OK] Enhanced UI build found with custom features
if exist "packages\electron-app\dist\renderer\assets" (
echo [OK] Pre-built UI assets found
) else (
echo [ERROR] Pre-built UI with enhancements not found!
echo [INFO] Run: npm run build to create the production build
echo [ERROR] Pre-built UI assets not found.
echo Run: npm run build
pause
exit /b 1
)
echo.
echo [STEP 3/3] Starting NomadArch (Production Mode)...
echo.
cd packages\electron-app
:: Run using npx electron with the built dist
echo [INFO] Starting Electron with pre-built enhanced UI...
pushd packages\electron-app
npx electron .
popd
if %ERRORLEVEL% neq 0 (
echo.
@@ -65,3 +54,4 @@ if %ERRORLEVEL% neq 0 (
)
pause
exit /b %ERRORLEVEL%

View File

@@ -1,35 +1,29 @@
@echo off
title NomadArch Launcher
color 0A
setlocal enabledelayedexpansion
title NomadArch Launcher
color 0A
echo.
echo ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗
echo ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║
echo ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║
echo ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║
echo ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║
echo ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
echo.
echo LAUNCHER - Enhanced with Auto-Fix Capabilities
echo ═════════════════════════════════════════════════════════════════════════════
echo NomadArch Launcher (Windows)
echo Version: 0.4.0
echo.
cd /d "%~dp0"
set SCRIPT_DIR=%~dp0
set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
cd /d "%SCRIPT_DIR%"
set ERRORS=0
set WARNINGS=0
set AUTO_FIXED=0
echo [STEP 1/5] Checking Dependencies...
echo.
echo [PREFLIGHT 1/7] Checking Dependencies...
where node >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [ERROR] Node.js not found!
echo.
echo Please install Node.js first: https://nodejs.org/
echo Then run: Install-Windows.bat
echo.
echo [WARN] Node.js not found. Running installer...
call "%SCRIPT_DIR%\Install-Windows.bat"
echo [INFO] If Node.js was installed, open a new terminal and run Launch-Windows.bat again.
pause
exit /b 1
)
@@ -40,7 +34,6 @@ echo [OK] Node.js: %NODE_VERSION%
where npm >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [ERROR] npm not found!
echo.
pause
exit /b 1
)
@@ -49,100 +42,162 @@ for /f "tokens=*" %%i in ('npm --version') do set NPM_VERSION=%%i
echo [OK] npm: %NPM_VERSION%
echo.
echo [STEP 2/5] Checking for OpenCode CLI...
echo.
echo [PREFLIGHT 2/7] Checking for OpenCode CLI...
where opencode >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [OK] OpenCode is available in PATH
echo [OK] OpenCode CLI available in PATH
) else (
if exist "bin\opencode.exe" (
echo [OK] OpenCode binary found in bin/ folder
echo [OK] OpenCode binary found in bin/
) else (
echo [WARN] OpenCode CLI not found
echo [INFO] Run Install-Windows.bat to install OpenCode
echo [INFO] Run Install-Windows.bat to set up OpenCode
set /a WARNINGS+=1
)
)
echo.
echo [STEP 3/5] Checking for Existing Build...
echo.
echo [PREFLIGHT 3/7] Checking Dependencies...
if exist "packages\ui\dist" (
echo [OK] UI build found
if not exist "node_modules" (
echo [INFO] Dependencies not installed. Installing now...
call npm install
if %ERRORLEVEL% neq 0 (
echo [ERROR] Dependency installation failed!
pause
exit /b 1
)
echo [OK] Dependencies installed (auto-fix)
set /a AUTO_FIXED+=1
) else (
echo [WARN] No UI build found. Building now...
echo [OK] Dependencies found
)
echo.
cd packages\ui
echo [PREFLIGHT 4/7] Finding Available Port...
set DEFAULT_SERVER_PORT=3001
set DEFAULT_UI_PORT=3000
set SERVER_PORT=%DEFAULT_SERVER_PORT%
set UI_PORT=%DEFAULT_UI_PORT%
for /l %%p in (%DEFAULT_SERVER_PORT%,1,3050) do (
netstat -ano | findstr ":%%p " | findstr "LISTENING" >nul
if !ERRORLEVEL! neq 0 (
set SERVER_PORT=%%p
goto :server_port_found
)
)
:server_port_found
echo [OK] Server port: !SERVER_PORT!
if !SERVER_PORT! neq %DEFAULT_SERVER_PORT% (
echo [INFO] Port %DEFAULT_SERVER_PORT% was in use, using !SERVER_PORT! instead
set /a WARNINGS+=1
)
echo.
echo [PREFLIGHT 5/7] Final Checks...
if not exist "packages\ui\dist\index.html" (
echo [WARN] UI build directory not found
echo [INFO] Running UI build...
pushd packages\ui
call npm run build
if %ERRORLEVEL% neq 0 (
echo [ERROR] UI build failed!
popd
set /a ERRORS+=1
) else (
echo [OK] UI build completed
goto :final_launch_check
)
cd ..\..
popd
echo [OK] UI build completed (auto-fix)
set /a AUTO_FIXED+=1
) else (
echo [OK] UI build directory exists
)
if not exist "packages\electron-app\dist\main\main.js" (
echo [WARN] Electron build incomplete
echo [INFO] Running full build...
call npm run build
if %ERRORLEVEL% neq 0 (
echo [ERROR] Full build failed!
set /a ERRORS+=1
goto :final_launch_check
)
echo [OK] Full build completed (auto-fix)
set /a AUTO_FIXED+=1
)
echo.
echo [STEP 4/5] Checking Port Availability...
echo [PREFLIGHT 6/7] Launch Summary
echo [STATUS]
echo.
set SERVER_PORT=3001
set UI_PORT=3000
netstat -ano | findstr ":%SERVER_PORT%" | findstr "LISTENING" >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [WARN] Port %SERVER_PORT% is already in use
echo [INFO] Another NomadArch instance or app may be running
echo [INFO] To find the process: netstat -ano | findstr ":%SERVER_PORT%"
echo [INFO] To kill it: taskkill /F /PID <PID>
set /a WARNINGS+=1
) else (
echo [OK] Port %SERVER_PORT% is available
)
netstat -ano | findstr ":%UI_PORT%" | findstr "LISTENING" >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [WARN] Port %UI_PORT% is already in use
echo [INFO] To find the process: netstat -ano | findstr ":%UI_PORT%"
echo [INFO] To kill it: taskkill /F /PID <PID>
set /a WARNINGS+=1
) else (
echo [OK] Port %UI_PORT% is available
)
echo.
echo [STEP 5/5] Starting NomadArch...
echo Node.js: %NODE_VERSION%
echo npm: %NPM_VERSION%
echo Auto-fixes applied: !AUTO_FIXED!
echo Warnings: %WARNINGS%
echo Errors: %ERRORS%
echo Server Port: !SERVER_PORT!
echo.
if %ERRORS% gtr 0 (
echo [ERROR] Cannot start due to errors!
echo [RESULT] Cannot start due to errors!
echo.
echo Please fix the errors above and try again.
pause
exit /b 1
)
echo [INFO] Starting NomadArch...
echo [INFO] Server will run on http://localhost:%SERVER_PORT%
echo [INFO] Server will run on http://localhost:!SERVER_PORT!
echo [INFO] UI will run on http://localhost:!UI_PORT!
echo [INFO] Press Ctrl+C to stop
echo.
set SERVER_URL=http://localhost:!SERVER_PORT!
set VITE_PORT=!UI_PORT!
echo.
echo ========================================
echo Starting UI dev server on port !UI_PORT!...
echo ========================================
pushd packages\ui
start "NomadArch UI Server" cmd /c "set VITE_PORT=!UI_PORT! && npm run dev"
popd
echo [INFO] Waiting for UI dev server to start...
timeout /t 3 /nobreak >nul
echo.
echo ========================================
echo Starting Electron app...
echo ========================================
set "VITE_DEV_SERVER_URL=http://localhost:!UI_PORT!"
set "NOMADARCH_OPEN_DEVTOOLS=false"
call npm run dev:electron
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 (see warnings above)
echo 4. Check the error message above for details
echo Error Code: %ERRORLEVEL%
echo.
echo To reinstall everything: Install-Windows.bat
echo Troubleshooting:
echo 1. Ensure port !SERVER_PORT! is not in use
echo 2. Run Install-Windows.bat again
echo 3. Check log file: packages\electron-app\.log
echo.
)
pause
:final_launch_check
echo.
echo Press any key to exit...
pause >nul
exit /b %ERRORS%

1746
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,8 @@
]
},
"scripts": {
"dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev": "npm run dev:electron --workspace @neuralnomads/codenomad-electron-app",
"dev:electron": "npm run dev:electron --workspace @neuralnomads/codenomad-electron-app",
"dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
"build": "npm run build --workspace @neuralnomads/codenomad-electron-app",
"build:tauri": "npm run build --workspace @codenomad/tauri-app",
@@ -23,5 +23,11 @@
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
},
"devDependencies": {
"rollup": "^4.54.0"
},
"optionalDependencies": {
"@esbuild/win32-x64": "^0.27.2"
}
}

View File

@@ -1,5 +1,17 @@
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import path from "path"
import type { CliProcessManager, CliStatus } from "./process-manager"
import {
listUsers,
createUser,
updateUser,
deleteUser,
verifyPassword,
setActiveUser,
createGuestUser,
getActiveUser,
getUserDataRoot,
} from "./user-store"
interface DialogOpenRequest {
mode: "directory" | "file"
@@ -40,6 +52,41 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return cliManager.start({ dev: devMode })
})
ipcMain.handle("users:list", async () => listUsers())
ipcMain.handle("users:active", async () => getActiveUser())
ipcMain.handle("users:create", async (_, payload: { name: string; password: string }) => {
const user = createUser(payload.name, payload.password)
return user
})
ipcMain.handle("users:update", async (_, payload: { id: string; name?: string; password?: string }) => {
const user = updateUser(payload.id, { name: payload.name, password: payload.password })
return user
})
ipcMain.handle("users:delete", async (_, payload: { id: string }) => {
deleteUser(payload.id)
return { success: true }
})
ipcMain.handle("users:createGuest", async () => {
const user = createGuestUser()
return user
})
ipcMain.handle("users:login", async (_, payload: { id: string; password?: string }) => {
const ok = verifyPassword(payload.id, payload.password ?? "")
if (!ok) {
return { success: false }
}
const user = setActiveUser(payload.id)
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 })
return { success: true, user }
})
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]

View File

@@ -5,6 +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"
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename)
@@ -225,6 +226,24 @@ function getPreloadPath() {
return join(mainDirname, "../preload/index.js")
}
function applyUserEnvToCli() {
const active = getActiveUser()
if (!active) {
const fallback = ensureDefaultUsers()
const fallbackRoot = getUserDataRoot(fallback.id)
cliManager.setUserEnv({
CODENOMAD_USER_DIR: fallbackRoot,
CLI_CONFIG: join(fallbackRoot, "config.json"),
})
return
}
const root = getUserDataRoot(active.id)
cliManager.setUserEnv({
CODENOMAD_USER_DIR: root,
CLI_CONFIG: join(root, "config.json"),
})
}
function destroyPreloadingView(target?: BrowserView | null) {
const view = target ?? preloadingView
if (!view) {
@@ -274,7 +293,7 @@ function createWindow() {
currentCliUrl = null
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development" && process.env.NOMADARCH_OPEN_DEVTOOLS === "true") {
mainWindow.webContents.openDevTools({ mode: "detach" })
}
@@ -452,6 +471,8 @@ if (isMac) {
}
app.whenReady().then(() => {
ensureDefaultUsers()
applyUserEnvToCli()
startCli()
if (isMac) {
@@ -480,6 +501,7 @@ app.whenReady().then(() => {
app.on("before-quit", async (event) => {
event.preventDefault()
await cliManager.stop().catch(() => { })
clearGuestUsers()
app.exit(0)
})

View File

@@ -79,6 +79,11 @@ export class CliProcessManager extends EventEmitter {
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
private userEnv: Record<string, string> = {}
setUserEnv(env: Record<string, string>) {
this.userEnv = { ...env }
}
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
@@ -100,6 +105,7 @@ export class CliProcessManager extends EventEmitter {
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
Object.assign(env, this.userEnv)
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
@@ -274,7 +280,8 @@ export class CliProcessManager extends EventEmitter {
const args = ["serve", "--host", host, "--port", "0"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
const uiPort = process.env.VITE_PORT || "3000"
args.push("--ui-dev-server", `http://localhost:${uiPort}`, "--log-level", "debug")
}
return args

View File

@@ -0,0 +1,267 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, cpSync } from "fs"
import os from "os"
import path from "path"
import crypto from "crypto"
interface UserRecord {
id: string
name: string
salt?: string
passwordHash?: string
isGuest?: boolean
createdAt: string
updatedAt: string
}
interface UserStoreState {
users: UserRecord[]
activeUserId?: string
}
const CONFIG_ROOT = path.join(os.homedir(), ".config", "codenomad")
const USERS_FILE = path.join(CONFIG_ROOT, "users.json")
const USERS_ROOT = path.join(CONFIG_ROOT, "users")
const LEGACY_ROOT = CONFIG_ROOT
const LEGACY_INTEGRATIONS_ROOT = path.join(os.homedir(), ".nomadarch")
function nowIso() {
return new Date().toISOString()
}
function sanitizeId(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9-_]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
}
function hashPassword(password: string, salt: string) {
return crypto.pbkdf2Sync(password, salt, 120000, 32, "sha256").toString("base64")
}
function generateSalt() {
return crypto.randomBytes(16).toString("base64")
}
function ensureDir(dir: string) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function readStore(): UserStoreState {
try {
if (!existsSync(USERS_FILE)) {
return { users: [] }
}
const content = readFileSync(USERS_FILE, "utf-8")
const parsed = JSON.parse(content) as UserStoreState
return {
users: Array.isArray(parsed.users) ? parsed.users : [],
activeUserId: parsed.activeUserId,
}
} catch {
return { users: [] }
}
}
function writeStore(state: UserStoreState) {
ensureDir(CONFIG_ROOT)
ensureDir(USERS_ROOT)
writeFileSync(USERS_FILE, JSON.stringify(state, null, 2), "utf-8")
}
function ensureUniqueId(base: string, existing: Set<string>) {
let candidate = sanitizeId(base) || "user"
let index = 1
while (existing.has(candidate)) {
candidate = `${candidate}-${index}`
index += 1
}
return candidate
}
function getUserDir(userId: string) {
return path.join(USERS_ROOT, userId)
}
function migrateLegacyData(targetDir: string) {
const legacyConfig = path.join(LEGACY_ROOT, "config.json")
const legacyInstances = path.join(LEGACY_ROOT, "instances")
const legacyWorkspaces = path.join(LEGACY_ROOT, "opencode-workspaces")
ensureDir(targetDir)
if (existsSync(legacyConfig)) {
cpSync(legacyConfig, path.join(targetDir, "config.json"), { force: true })
}
if (existsSync(legacyInstances)) {
cpSync(legacyInstances, path.join(targetDir, "instances"), { recursive: true, force: true })
}
if (existsSync(legacyWorkspaces)) {
cpSync(legacyWorkspaces, path.join(targetDir, "opencode-workspaces"), { recursive: true, force: true })
}
if (existsSync(LEGACY_INTEGRATIONS_ROOT)) {
cpSync(LEGACY_INTEGRATIONS_ROOT, path.join(targetDir, "integrations"), { recursive: true, force: true })
}
}
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
writeStore(store)
}
return active
}
const existingIds = new Set<string>()
const userId = ensureUniqueId("roman", existingIds)
const salt = generateSalt()
const passwordHash = hashPassword("q1w2e3r4", salt)
const record: UserRecord = {
id: userId,
name: "roman",
salt,
passwordHash,
createdAt: nowIso(),
updatedAt: nowIso(),
}
store.users.push(record)
store.activeUserId = record.id
writeStore(store)
const userDir = getUserDir(record.id)
migrateLegacyData(userDir)
return record
}
export function listUsers(): UserRecord[] {
return readStore().users
}
export function getActiveUser(): UserRecord | null {
const store = readStore()
if (!store.activeUserId) return null
return store.users.find((user) => user.id === store.activeUserId) ?? null
}
export function setActiveUser(userId: string) {
const store = readStore()
const user = store.users.find((u) => u.id === userId)
if (!user) {
throw new Error("User not found")
}
store.activeUserId = userId
writeStore(store)
return user
}
export function createUser(name: string, password: string) {
const store = readStore()
const existingIds = new Set(store.users.map((u) => u.id))
const id = ensureUniqueId(name, existingIds)
const salt = generateSalt()
const passwordHash = hashPassword(password, salt)
const record: UserRecord = {
id,
name,
salt,
passwordHash,
createdAt: nowIso(),
updatedAt: nowIso(),
}
store.users.push(record)
writeStore(store)
ensureDir(getUserDir(id))
return record
}
export function createGuestUser() {
const store = readStore()
const existingIds = new Set(store.users.map((u) => u.id))
const id = ensureUniqueId(`guest-${crypto.randomUUID().slice(0, 8)}`, existingIds)
const record: UserRecord = {
id,
name: "Guest",
isGuest: true,
createdAt: nowIso(),
updatedAt: nowIso(),
}
store.users.push(record)
store.activeUserId = id
writeStore(store)
ensureDir(getUserDir(id))
return record
}
export function updateUser(userId: string, updates: { name?: string; password?: string }) {
const store = readStore()
const target = store.users.find((u) => u.id === userId)
if (!target) {
throw new Error("User not found")
}
if (updates.name) {
target.name = updates.name
}
if (updates.password && !target.isGuest) {
const salt = generateSalt()
target.salt = salt
target.passwordHash = hashPassword(updates.password, salt)
}
target.updatedAt = nowIso()
writeStore(store)
return target
}
export function deleteUser(userId: string) {
const store = readStore()
const target = store.users.find((u) => u.id === userId)
if (!target) return
store.users = store.users.filter((u) => u.id !== userId)
if (store.activeUserId === userId) {
store.activeUserId = store.users[0]?.id
}
writeStore(store)
const dir = getUserDir(userId)
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true })
}
}
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.isGuest) return true
if (!user.salt || !user.passwordHash) return false
return hashPassword(password, user.salt) === user.passwordHash
}
export function getUserDataRoot(userId: string) {
return getUserDir(userId)
}
export function clearGuestUsers() {
const store = readStore()
const guests = store.users.filter((u) => u.isGuest)
if (guests.length === 0) return
store.users = store.users.filter((u) => !u.isGuest)
if (store.activeUserId && guests.some((u) => u.id === store.activeUserId)) {
store.activeUserId = store.users[0]?.id
}
writeStore(store)
for (const guest of guests) {
const dir = getUserDir(guest.id)
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true })
}
}
}

View File

@@ -12,6 +12,13 @@ const electronAPI = {
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
listUsers: () => ipcRenderer.invoke("users:list"),
getActiveUser: () => ipcRenderer.invoke("users:active"),
createUser: (payload) => ipcRenderer.invoke("users:create", payload),
updateUser: (payload) => ipcRenderer.invoke("users:update", payload),
deleteUser: (payload) => ipcRenderer.invoke("users:delete", payload),
createGuest: () => ipcRenderer.invoke("users:createGuest"),
loginUser: (payload) => ipcRenderer.invoke("users:login", payload),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -15,7 +15,7 @@
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": {
"dev": "electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"dev:electron": "cross-env NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview",
@@ -40,6 +40,7 @@
"devDependencies": {
"7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",

View File

@@ -79,6 +79,37 @@ export type WorkspaceCreateResponse = WorkspaceDescriptor
export type WorkspaceListResponse = WorkspaceDescriptor[]
export type WorkspaceDetailResponse = WorkspaceDescriptor
export interface WorkspaceExportRequest {
destination: string
includeConfig?: boolean
}
export interface WorkspaceExportResponse {
destination: string
}
export interface WorkspaceImportRequest {
source: string
destination: string
includeConfig?: boolean
}
export type WorkspaceImportResponse = WorkspaceDescriptor
export interface WorkspaceMcpConfig {
mcpServers?: Record<string, unknown>
}
export interface WorkspaceMcpConfigResponse {
path: string
exists: boolean
config: WorkspaceMcpConfig
}
export interface WorkspaceMcpConfigRequest {
config: WorkspaceMcpConfig
}
export interface WorkspaceDeleteResponse {
id: string
status: WorkspaceStatus
@@ -159,6 +190,11 @@ export interface InstanceData {
agentModelSelections: AgentModelSelection
sessionTasks?: SessionTasks // Multi-task chat support: tasks per session
sessionSkills?: Record<string, SkillSelection[]> // Selected skills per session
customAgents?: Array<{
name: string
description?: string
prompt: string
}>
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
@@ -269,6 +305,10 @@ export interface ServerMeta {
latestRelease?: LatestReleaseInfo
}
export interface PortAvailabilityResponse {
port: number
}
export type {
Preferences,
ModelPreference,

View File

@@ -16,6 +16,7 @@ import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { getUserConfigPath } from "./user-data"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
@@ -41,7 +42,7 @@ interface CliOptions {
const DEFAULT_PORT = 9898
const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
const DEFAULT_CONFIG_PATH = getUserConfigPath()
function parseCliOptions(argv: string[]): CliOptions {
const program = new Command()

View File

@@ -1,11 +1,5 @@
/**
* Ollama Cloud API Integration
* Provides access to Ollama's cloud models through API
*/
import { z } from "zod"
// Configuration schema for Ollama Cloud
export const OllamaCloudConfigSchema = z.object({
apiKey: z.string().optional(),
endpoint: z.string().default("https://ollama.com"),
@@ -14,31 +8,56 @@ export const OllamaCloudConfigSchema = z.object({
export type OllamaCloudConfig = z.infer<typeof OllamaCloudConfigSchema>
// Model information schema
// Schema is flexible since Ollama Cloud may return different fields than local Ollama
export const OllamaModelSchema = z.object({
name: z.string(),
size: z.string(),
digest: z.string(),
modified_at: z.string(),
created_at: z.string()
model: z.string().optional(), // Some APIs return model instead of name
size: z.union([z.string(), z.number()]).optional(),
digest: z.string().optional(),
modified_at: z.string().optional(),
created_at: z.string().optional(),
details: z.any().optional() // Model details like family, parameter_size, etc.
})
export type OllamaModel = z.infer<typeof OllamaModelSchema>
// Chat message schema
export const ChatMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
images: z.array(z.string()).optional()
images: z.array(z.string()).optional(),
tool_calls: z.array(z.any()).optional(),
thinking: z.string().optional()
})
export type ChatMessage = z.infer<typeof ChatMessageSchema>
// Chat request/response schemas
export const ToolCallSchema = z.object({
name: z.string(),
arguments: z.record(z.any())
})
export type ToolCall = z.infer<typeof ToolCallSchema>
export const ToolDefinitionSchema = z.object({
name: z.string(),
description: z.string(),
parameters: z.object({
type: z.enum(["object", "string", "number", "boolean", "array"]),
properties: z.record(z.any()),
required: z.array(z.string()).optional()
})
})
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>
export const ChatRequestSchema = z.object({
model: z.string(),
messages: z.array(ChatMessageSchema),
stream: z.boolean().default(false),
think: z.union([z.boolean(), z.enum(["low", "medium", "high"])]).optional(),
format: z.union([z.literal("json"), z.any()]).optional(),
tools: z.array(ToolDefinitionSchema).optional(),
web_search: z.boolean().optional(),
options: z.object({
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional()
@@ -48,7 +67,10 @@ export const ChatRequestSchema = z.object({
export const ChatResponseSchema = z.object({
model: z.string(),
created_at: z.string(),
message: ChatMessageSchema,
message: ChatMessageSchema.extend({
thinking: z.string().optional(),
tool_calls: z.array(z.any()).optional()
}),
done: z.boolean().optional(),
total_duration: z.number().optional(),
load_duration: z.number().optional(),
@@ -61,23 +83,32 @@ export const ChatResponseSchema = z.object({
export type ChatRequest = z.infer<typeof ChatRequestSchema>
export type ChatResponse = z.infer<typeof ChatResponseSchema>
export const EmbeddingRequestSchema = z.object({
model: z.string(),
input: z.union([z.string(), z.array(z.string())])
})
export type EmbeddingRequest = z.infer<typeof EmbeddingRequestSchema>
export const EmbeddingResponseSchema = z.object({
model: z.string(),
embeddings: z.array(z.array(z.number()))
})
export type EmbeddingResponse = z.infer<typeof EmbeddingResponseSchema>
export class OllamaCloudClient {
private config: OllamaCloudConfig
private baseUrl: string
constructor(config: OllamaCloudConfig) {
this.config = config
this.baseUrl = config.endpoint.replace(/\/$/, "") // Remove trailing slash
this.baseUrl = config.endpoint.replace(/\/$/, "")
}
/**
* Test connection to Ollama Cloud API
*/
async testConnection(): Promise<boolean> {
try {
const response = await this.makeRequest("/api/tags", {
method: "GET"
})
const response = await this.makeRequest("/tags", { method: "GET" })
return response.ok
} catch (error) {
console.error("Ollama Cloud connection test failed:", error)
@@ -85,30 +116,85 @@ export class OllamaCloudClient {
}
}
/**
* List available models
*/
async listModels(): Promise<OllamaModel[]> {
try {
const response = await this.makeRequest("/api/tags", {
method: "GET"
const headers: Record<string, string> = {}
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
const cloudResponse = await fetch(`${this.baseUrl}/v1/models`, {
method: "GET",
headers
})
if (cloudResponse.ok) {
const data = await cloudResponse.json()
const modelsArray = Array.isArray(data?.data) ? data.data : []
const parsedModels = modelsArray
.map((model: any) => ({
name: model.id || model.name || model.model,
model: model.id || model.model || model.name,
}))
.filter((model: any) => model.name)
if (parsedModels.length > 0) {
return parsedModels
}
}
const response = await this.makeRequest("/tags", { method: "GET" })
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`)
const errorText = await response.text().catch(() => "Unknown error")
console.error(`[OllamaCloud] Failed to fetch models: ${response.status} ${response.statusText}`, errorText)
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText} - ${errorText}`)
}
const data = await response.json()
return z.array(OllamaModelSchema).parse(data.models || [])
console.log("[OllamaCloud] Models response:", JSON.stringify(data).substring(0, 500))
// Handle different response formats flexibly
const modelsArray = Array.isArray(data.models) ? data.models :
Array.isArray(data) ? data : []
// Parse with flexible schema, don't throw on validation failure
// Only include cloud-compatible models (ending in -cloud or known cloud models)
const parsedModels: OllamaModel[] = []
for (const model of modelsArray) {
try {
const modelName = model.name || model.model || ""
// Filter to only cloud-compatible models
const isCloudModel = modelName.endsWith("-cloud") ||
modelName.includes(":cloud") ||
modelName.startsWith("gpt-oss") ||
modelName.startsWith("qwen3-coder") ||
modelName.startsWith("deepseek-v3")
if (modelName && isCloudModel) {
parsedModels.push({
name: modelName,
model: model.model || modelName,
size: model.size,
digest: model.digest,
modified_at: model.modified_at,
created_at: model.created_at,
details: model.details
})
}
} catch (parseError) {
console.warn("[OllamaCloud] Skipping model due to parse error:", model, parseError)
}
}
console.log(`[OllamaCloud] Parsed ${parsedModels.length} cloud-compatible models`)
return parsedModels
} catch (error) {
console.error("Failed to list Ollama Cloud models:", error)
throw error
}
}
/**
* Generate chat completion
*/
async chat(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
if (!this.config.apiKey) {
throw new Error("Ollama Cloud API key is required")
@@ -118,20 +204,20 @@ export class OllamaCloudClient {
"Content-Type": "application/json"
}
// Add authorization header if API key is provided
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
try {
const response = await fetch(`${this.baseUrl}/api/chat`, {
const response = await this.makeRequest("/chat", {
method: "POST",
headers,
body: JSON.stringify(request)
})
if (!response.ok) {
throw new Error(`Chat request failed: ${response.statusText}`)
const errorText = await response.text()
throw new Error(`Chat request failed: ${response.statusText} - ${errorText}`)
}
if (request.stream) {
@@ -146,9 +232,85 @@ export class OllamaCloudClient {
}
}
/**
* Pull a model (for cloud models, this just makes them available)
*/
async chatWithThinking(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
const requestWithThinking = {
...request,
think: true
}
return this.chat(requestWithThinking)
}
async chatWithStructuredOutput(request: ChatRequest, schema: any): Promise<AsyncIterable<ChatResponse>> {
const requestWithFormat = {
...request,
format: schema
}
return this.chat(requestWithFormat)
}
async chatWithVision(request: ChatRequest, images: string[]): Promise<AsyncIterable<ChatResponse>> {
if (!request.messages.length) {
throw new Error("At least one message is required")
}
const messagesWithImages = [...request.messages]
const lastUserMessage = messagesWithImages.slice().reverse().find(m => m.role === "user")
if (lastUserMessage) {
lastUserMessage.images = images
}
return this.chat({ ...request, messages: messagesWithImages })
}
async chatWithTools(request: ChatRequest, tools: ToolDefinition[]): Promise<AsyncIterable<ChatResponse>> {
const requestWithTools = {
...request,
tools
}
return this.chat(requestWithTools)
}
async chatWithWebSearch(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
const requestWithWebSearch = {
...request,
web_search: true
}
return this.chat(requestWithWebSearch)
}
async generateEmbeddings(request: EmbeddingRequest): Promise<EmbeddingResponse> {
if (!this.config.apiKey) {
throw new Error("Ollama Cloud API key is required")
}
const headers: Record<string, string> = {
"Content-Type": "application/json"
}
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
try {
const response = await this.makeRequest("/embed", {
method: "POST",
headers,
body: JSON.stringify(request)
})
if (!response.ok) {
throw new Error(`Embeddings request failed: ${response.statusText}`)
}
const data = await response.json()
return EmbeddingResponseSchema.parse(data)
} catch (error) {
console.error("Ollama Cloud embeddings request failed:", error)
throw error
}
}
async pullModel(modelName: string): Promise<void> {
const headers: Record<string, string> = {
"Content-Type": "application/json"
@@ -158,7 +320,7 @@ export class OllamaCloudClient {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
const response = await fetch(`${this.baseUrl}/api/pull`, {
const response = await this.makeRequest("/pull", {
method: "POST",
headers,
body: JSON.stringify({ name: modelName })
@@ -169,9 +331,6 @@ export class OllamaCloudClient {
}
}
/**
* Parse streaming response
*/
private async *parseStreamingResponse(response: Response): AsyncIterable<ChatResponse> {
if (!response.body) {
throw new Error("Response body is missing")
@@ -197,7 +356,6 @@ export class OllamaCloudClient {
return
}
} catch (parseError) {
// Skip invalid JSON lines
console.warn("Failed to parse streaming line:", line, parseError)
}
}
@@ -207,61 +365,72 @@ export class OllamaCloudClient {
}
}
/**
* Create async iterable from array
*/
private async *createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
for (const item of items) {
yield item
}
}
/**
* Make authenticated request to API
*/
private async makeRequest(endpoint: string, options: RequestInit): Promise<Response> {
const url = `${this.baseUrl}${endpoint}`
// Ensure endpoint starts with /api
const apiEndpoint = endpoint.startsWith('/api') ? endpoint : `/api${endpoint}`
const url = `${this.baseUrl}${apiEndpoint}`
const headers: Record<string, string> = {
...options.headers as Record<string, string>
}
// Add authorization header if API key is provided
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
console.log(`[OllamaCloud] Making request to: ${url}`)
return fetch(url, {
...options,
headers
})
}
/**
* Get cloud-specific models (models ending with -cloud)
*/
async getCloudModels(): Promise<OllamaModel[]> {
const allModels = await this.listModels()
return allModels.filter(model => model.name.endsWith("-cloud"))
}
/**
* Validate API key format
*/
static validateApiKey(apiKey: string): boolean {
return typeof apiKey === "string" && apiKey.length > 0
}
/**
* Get available cloud model names
*/
async getCloudModelNames(): Promise<string[]> {
const cloudModels = await this.getCloudModels()
return cloudModels.map(model => model.name)
}
async getThinkingCapableModels(): Promise<string[]> {
const allModels = await this.listModels()
const thinkingModelPatterns = ["qwen3", "deepseek-r1", "gpt-oss", "deepseek-v3.1"]
return allModels
.map(m => m.name)
.filter(name => thinkingModelPatterns.some(pattern => name.toLowerCase().includes(pattern)))
}
async getVisionCapableModels(): Promise<string[]> {
const allModels = await this.listModels()
const visionModelPatterns = ["gemma3", "llama3.2-vision", "llava", "bakllava", "minicpm-v"]
return allModels
.map(m => m.name)
.filter(name => visionModelPatterns.some(pattern => name.toLowerCase().includes(pattern)))
}
async getEmbeddingModels(): Promise<string[]> {
const allModels = await this.listModels()
const embeddingModelPatterns = ["embeddinggemma", "qwen3-embedding", "all-minilm", "nomic-embed", "mxbai-embed"]
return allModels
.map(m => m.name)
.filter(name => embeddingModelPatterns.some(pattern => name.toLowerCase().includes(pattern)))
}
}
// Default cloud models based on Ollama documentation
export const DEFAULT_CLOUD_MODELS = [
"gpt-oss:120b-cloud",
"llama3.1:70b-cloud",
@@ -271,3 +440,31 @@ export const DEFAULT_CLOUD_MODELS = [
] as const
export type CloudModelName = typeof DEFAULT_CLOUD_MODELS[number]
export const THINKING_MODELS = [
"qwen3",
"deepseek-r1",
"deepseek-v3.1",
"gpt-oss:120b-cloud"
] as const
export type ThinkingModelName = typeof THINKING_MODELS[number]
export const VISION_MODELS = [
"gemma3",
"llava",
"bakllava",
"minicpm-v"
] as const
export type VisionModelName = typeof VISION_MODELS[number]
export const EMBEDDING_MODELS = [
"embeddinggemma",
"qwen3-embedding",
"all-minilm",
"nomic-embed-text",
"mxbai-embed-large"
] as const
export type EmbeddingModelName = typeof EMBEDDING_MODELS[number]

View File

@@ -11,8 +11,8 @@ import { z } from "zod"
// Configuration schema for OpenCode Zen
export const OpenCodeZenConfigSchema = z.object({
enabled: z.boolean().default(true), // Free models enabled by default
endpoint: z.string().default("https://api.opencode.ai/v1"),
apiKey: z.string().default("public") // "public" key for free models
endpoint: z.string().default("https://opencode.ai/zen/v1"),
apiKey: z.string().optional()
})
export type OpenCodeZenConfig = z.infer<typeof OpenCodeZenConfigSchema>
@@ -104,10 +104,10 @@ export const FREE_ZEN_MODELS: ZenModel[] = [
attachment: false,
temperature: true,
cost: { input: 0, output: 0 },
limit: { context: 128000, output: 16384 }
limit: { context: 200000, output: 128000 }
},
{
id: "grok-code-fast-1",
id: "grok-code",
name: "Grok Code Fast 1",
family: "grok",
reasoning: true,
@@ -115,18 +115,29 @@ export const FREE_ZEN_MODELS: ZenModel[] = [
attachment: false,
temperature: true,
cost: { input: 0, output: 0 },
limit: { context: 256000, output: 10000 }
limit: { context: 256000, output: 256000 }
},
{
id: "minimax-m2.1",
name: "MiniMax M2.1",
family: "minimax",
id: "glm-4.7-free",
name: "GLM-4.7",
family: "glm-free",
reasoning: true,
tool_call: true,
attachment: false,
temperature: true,
cost: { input: 0, output: 0 },
limit: { context: 205000, output: 131072 }
limit: { context: 204800, output: 131072 }
},
{
id: "alpha-doubao-seed-code",
name: "Doubao Seed Code (alpha)",
family: "doubao",
reasoning: true,
tool_call: true,
attachment: false,
temperature: true,
cost: { input: 0, output: 0 },
limit: { context: 256000, output: 32000 }
}
]
@@ -217,13 +228,19 @@ export class OpenCodeZenClient {
* Chat completion (streaming)
*/
async *chatStream(request: ChatRequest): AsyncGenerator<ChatChunk> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "NomadArch/1.0",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "NomadArch"
}
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.config.apiKey}`,
"User-Agent": "NomadArch/1.0"
},
headers,
body: JSON.stringify({
...request,
stream: true
@@ -281,13 +298,19 @@ export class OpenCodeZenClient {
* Chat completion (non-streaming)
*/
async chat(request: ChatRequest): Promise<ChatChunk> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "NomadArch/1.0",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "NomadArch"
}
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.config.apiKey}`,
"User-Agent": "NomadArch/1.0"
},
headers,
body: JSON.stringify({
...request,
stream: false
@@ -306,7 +329,6 @@ export class OpenCodeZenClient {
export function getDefaultZenConfig(): OpenCodeZenConfig {
return {
enabled: true,
endpoint: "https://api.opencode.ai/v1",
apiKey: "public"
endpoint: "https://opencode.ai/zen/v1"
}
}

View File

@@ -1,113 +1,111 @@
/**
* Z.AI API Integration
* Provides access to Z.AI's GLM Coding Plan API (Anthropic-compatible)
* Based on https://docs.z.ai/devpack/tool/claude#step-2-config-glm-coding-plan
*/
import { z } from "zod"
// Configuration schema for Z.AI
export const ZAIConfigSchema = z.object({
apiKey: z.string().optional(),
endpoint: z.string().default("https://api.z.ai/api/anthropic"),
endpoint: z.string().default("https://api.z.ai/api/paas/v4"),
enabled: z.boolean().default(false),
timeout: z.number().default(3000000) // 50 minutes as per docs
timeout: z.number().default(300000)
})
export type ZAIConfig = z.infer<typeof ZAIConfigSchema>
// Message schema (Anthropic-compatible)
export const ZAIMessageSchema = z.object({
role: z.enum(["user", "assistant"]),
role: z.enum(["user", "assistant", "system"]),
content: z.string()
})
export type ZAIMessage = z.infer<typeof ZAIMessageSchema>
// Chat request schema
export const ZAIChatRequestSchema = z.object({
model: z.string().default("claude-sonnet-4-20250514"),
model: z.string().default("glm-4.7"),
messages: z.array(ZAIMessageSchema),
max_tokens: z.number().default(8192),
stream: z.boolean().default(true),
system: z.string().optional()
temperature: z.number().optional(),
thinking: z.object({
type: z.enum(["enabled", "disabled"]).optional()
}).optional()
})
export type ZAIChatRequest = z.infer<typeof ZAIChatRequestSchema>
// Chat response schema
export const ZAIChatResponseSchema = z.object({
id: z.string(),
type: z.string(),
role: z.string(),
content: z.array(z.object({
type: z.string(),
text: z.string().optional()
})),
object: z.string(),
created: z.number(),
model: z.string(),
stop_reason: z.string().nullable().optional(),
stop_sequence: z.string().nullable().optional(),
choices: z.array(z.object({
index: z.number(),
message: z.object({
role: z.string(),
content: z.string().optional(),
reasoning_content: z.string().optional()
}),
finish_reason: z.string()
})),
usage: z.object({
input_tokens: z.number(),
output_tokens: z.number()
}).optional()
prompt_tokens: z.number(),
completion_tokens: z.number(),
total_tokens: z.number()
})
})
export type ZAIChatResponse = z.infer<typeof ZAIChatResponseSchema>
// Stream chunk schema
export const ZAIStreamChunkSchema = z.object({
type: z.string(),
index: z.number().optional(),
delta: z.object({
type: z.string().optional(),
text: z.string().optional()
}).optional(),
message: z.object({
id: z.string(),
type: z.string(),
role: z.string(),
content: z.array(z.any()),
model: z.string()
}).optional(),
content_block: z.object({
type: z.string(),
text: z.string()
}).optional()
object: z.string(),
created: z.number(),
model: z.string(),
choices: z.array(z.object({
index: z.number(),
delta: z.object({
role: z.string().optional(),
content: z.string().optional(),
reasoning_content: z.string().optional()
}),
finish_reason: z.string().nullable().optional()
}))
})
export type ZAIStreamChunk = z.infer<typeof ZAIStreamChunkSchema>
export const ZAI_MODELS = [
"glm-4.7",
"glm-4.6",
"glm-4.5",
"glm-4.5-air",
"glm-4.5-flash",
"glm-4.5-long"
] as const
export type ZAIModelName = typeof ZAI_MODELS[number]
export class ZAIClient {
private config: ZAIConfig
private baseUrl: string
constructor(config: ZAIConfig) {
this.config = config
this.baseUrl = config.endpoint.replace(/\/$/, "") // Remove trailing slash
this.baseUrl = config.endpoint.replace(/\/$/, "")
}
/**
* Test connection to Z.AI API
*/
async testConnection(): Promise<boolean> {
if (!this.config.apiKey) {
return false
}
try {
// Make a minimal request to test auth
const response = await fetch(`${this.baseUrl}/v1/messages`, {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
model: "glm-4.7",
max_tokens: 1,
messages: [{ role: "user", content: "test" }]
})
})
// Any response other than auth error means connection works
return response.status !== 401 && response.status !== 403
} catch (error) {
console.error("Z.AI connection test failed:", error)
@@ -115,28 +113,16 @@ export class ZAIClient {
}
}
/**
* List available models
*/
async listModels(): Promise<string[]> {
// Z.AI provides access to Claude models through their proxy
return [
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-opus-20240229",
"claude-3-haiku-20240307"
]
return [...ZAI_MODELS]
}
/**
* Chat completion (streaming)
*/
async *chatStream(request: ZAIChatRequest): AsyncGenerator<ZAIStreamChunk> {
if (!this.config.apiKey) {
throw new Error("Z.AI API key is required")
}
const response = await fetch(`${this.baseUrl}/v1/messages`, {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({
@@ -165,7 +151,7 @@ export class ZAIClient {
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || "" // Keep incomplete line in buffer
buffer = lines.pop() || ""
for (const line of lines) {
if (line.startsWith("data: ")) {
@@ -176,7 +162,6 @@ export class ZAIClient {
const parsed = JSON.parse(data)
yield parsed as ZAIStreamChunk
} catch (e) {
// Skip invalid JSON
}
}
}
@@ -186,15 +171,12 @@ export class ZAIClient {
}
}
/**
* Chat completion (non-streaming)
*/
async chat(request: ZAIChatRequest): Promise<ZAIChatResponse> {
if (!this.config.apiKey) {
throw new Error("Z.AI API key is required")
}
const response = await fetch(`${this.baseUrl}/v1/messages`, {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({
@@ -211,31 +193,14 @@ export class ZAIClient {
return await response.json()
}
/**
* Get request headers
*/
private getHeaders(): Record<string, string> {
return {
"Content-Type": "application/json",
"x-api-key": this.config.apiKey || "",
"anthropic-version": "2023-06-01"
"Authorization": `Bearer ${this.config.apiKey}`
}
}
/**
* Validate API key
*/
static validateApiKey(apiKey: string): boolean {
return typeof apiKey === "string" && apiKey.length > 0
}
}
// Default available models
export const ZAI_MODELS = [
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-opus-20240229",
"claude-3-haiku-20240307"
] as const
export type ZAIModelName = typeof ZAI_MODELS[number]

View File

@@ -3,6 +3,7 @@ import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
import { getOpencodeWorkspacesRoot, getUserDataRoot } from "./user-data"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
@@ -12,7 +13,8 @@ const prodTemplateDir = path.resolve(__dirname, "opencode-config")
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild ? devTemplateDir : prodTemplateDir
const userConfigDir = path.join(os.homedir(), ".config", "codenomad", "opencode-config")
const userConfigDir = path.join(getUserDataRoot(), "opencode-config")
const workspaceConfigRoot = getOpencodeWorkspacesRoot()
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {
@@ -28,6 +30,28 @@ export function getOpencodeConfigDir(): string {
return userConfigDir
}
export function ensureWorkspaceOpencodeConfig(workspaceId: string): string {
if (!workspaceId) {
return getOpencodeConfigDir()
}
if (!existsSync(templateDir)) {
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
}
const targetDir = path.join(workspaceConfigRoot, workspaceId)
if (existsSync(targetDir)) {
return targetDir
}
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(templateDir, targetDir, { recursive: true })
return targetDir
}
export function getWorkspaceOpencodeConfigDir(workspaceId: string): string {
return path.join(workspaceConfigRoot, workspaceId)
}
function refreshUserConfig() {
log.debug({ templateDir, userConfigDir }, "Syncing Opencode config template")
rmSync(userConfigDir, { recursive: true, force: true })

View File

@@ -105,7 +105,11 @@ export function createHttpServer(deps: HttpServerDeps) {
},
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerWorkspaceRoutes(app, {
workspaceManager: deps.workspaceManager,
instanceStore: deps.instanceStore,
configStore: deps.configStore,
})
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
@@ -119,7 +123,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerQwenRoutes(app, { logger: deps.logger })
registerZAIRoutes(app, { logger: deps.logger })
registerOpenCodeZenRoutes(app, { logger: deps.logger })
await registerSkillsRoutes(app)
registerSkillsRoutes(app)
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify"
import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types"
import { NetworkAddress, ServerMeta, PortAvailabilityResponse } from "../../api-types"
import { getAvailablePort } from "../../utils/port"
interface RouteDeps {
serverMeta: ServerMeta
@@ -8,6 +9,11 @@ interface RouteDeps {
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
app.get("/api/ports/available", async () => {
const port = await getAvailablePort(3000)
const response: PortAvailabilityResponse = { port }
return response
})
}
function buildMetaResponse(meta: ServerMeta): ServerMeta {

View File

@@ -1,6 +1,18 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { OllamaCloudClient, type OllamaCloudConfig, type ChatRequest } from "../../integrations/ollama-cloud"
import {
OllamaCloudClient,
type OllamaCloudConfig,
type ChatRequest,
type EmbeddingRequest,
type ToolDefinition
} from "../../integrations/ollama-cloud"
import { Logger } from "../../logger"
import fs from "fs"
import path from "path"
import { getUserIntegrationsDir } from "../../user-data"
const CONFIG_DIR = getUserIntegrationsDir()
const CONFIG_FILE = path.join(CONFIG_DIR, "ollama-config.json")
interface OllamaRouteDeps {
logger: Logger
@@ -12,7 +24,6 @@ export async function registerOllamaRoutes(
) {
const logger = deps.logger.child({ component: "ollama-routes" })
// Get Ollama Cloud configuration
app.get('/api/ollama/config', async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -23,9 +34,9 @@ export async function registerOllamaRoutes(
}
})
// Update Ollama Cloud configuration
app.post('/api/ollama/config', {
schema: {
body: {
type: 'object',
required: ['enabled'],
properties: {
@@ -34,6 +45,7 @@ export async function registerOllamaRoutes(
endpoint: { type: 'string' }
}
}
}
}, async (request, reply) => {
try {
const { enabled, apiKey, endpoint } = request.body as any
@@ -46,7 +58,6 @@ export async function registerOllamaRoutes(
}
})
// Test Ollama Cloud connection
app.post('/api/ollama/test', async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -64,7 +75,6 @@ export async function registerOllamaRoutes(
}
})
// List available models
app.get('/api/ollama/models', async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -72,17 +82,19 @@ export async function registerOllamaRoutes(
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
logger.info({ endpoint: config.endpoint, hasApiKey: !!config.apiKey }, "Fetching Ollama models")
const client = new OllamaCloudClient(config)
const models = await client.listModels()
logger.info({ modelCount: models.length }, "Ollama models fetched successfully")
return { models }
} catch (error) {
logger.error({ error }, "Failed to list Ollama models")
return reply.status(500).send({ error: "Failed to list models" })
} catch (error: any) {
logger.error({ error: error?.message || error }, "Failed to list Ollama models")
return reply.status(500).send({ error: error?.message || "Failed to list models" })
}
})
// Get cloud models only
app.get('/api/ollama/models/cloud', async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -100,9 +112,60 @@ export async function registerOllamaRoutes(
}
})
// Chat completion endpoint
app.get('/api/ollama/models/thinking', async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const thinkingModels = await client.getThinkingCapableModels()
return { models: thinkingModels }
} catch (error) {
logger.error({ error }, "Failed to list thinking models")
return reply.status(500).send({ error: "Failed to list thinking models" })
}
})
app.get('/api/ollama/models/vision', async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const visionModels = await client.getVisionCapableModels()
return { models: visionModels }
} catch (error) {
logger.error({ error }, "Failed to list vision models")
return reply.status(500).send({ error: "Failed to list vision models" })
}
})
app.get('/api/ollama/models/embedding', async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const embeddingModels = await client.getEmbeddingModels()
return { models: embeddingModels }
} catch (error) {
logger.error({ error }, "Failed to list embedding models")
return reply.status(500).send({ error: "Failed to list embedding models" })
}
})
app.post('/api/ollama/chat', {
schema: {
body: {
type: 'object',
required: ['model', 'messages'],
properties: {
@@ -119,6 +182,10 @@ export async function registerOllamaRoutes(
}
},
stream: { type: 'boolean' },
think: { type: ['boolean', 'string'] },
format: { type: ['string', 'object'] },
tools: { type: 'array' },
web_search: { type: 'boolean' },
options: {
type: 'object',
properties: {
@@ -128,6 +195,7 @@ export async function registerOllamaRoutes(
}
}
}
}
}, async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -138,7 +206,6 @@ export async function registerOllamaRoutes(
const client = new OllamaCloudClient(config)
const chatRequest = request.body as ChatRequest
// Set appropriate headers for streaming
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
@@ -159,13 +226,20 @@ export async function registerOllamaRoutes(
}
reply.raw.end()
} catch (streamError) {
logger.error({ error: streamError }, "Streaming failed")
} catch (streamError: any) {
logger.error({ error: streamError?.message || streamError }, "Ollama streaming failed")
// Send error event to client so it knows the request failed
reply.raw.write(`data: ${JSON.stringify({ error: streamError?.message || "Streaming failed" })}\n\n`)
reply.raw.write('data: [DONE]\n\n')
reply.raw.end()
}
} else {
const response = await client.chat(chatRequest)
return response
const stream = await client.chat(chatRequest)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama chat request failed")
@@ -173,15 +247,291 @@ export async function registerOllamaRoutes(
}
})
// Pull model endpoint
app.post('/api/ollama/chat/thinking', {
schema: {
body: {
type: 'object',
required: ['model', 'messages'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
stream: { type: 'boolean' },
think: { type: ['boolean', 'string'] }
}
}
}
}, async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const chatRequest = request.body as ChatRequest
chatRequest.think = chatRequest.think ?? true
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
try {
const stream = await client.chatWithThinking(chatRequest)
for await (const chunk of stream) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
if (chunk.done) {
reply.raw.write('data: [DONE]\n\n')
break
}
}
reply.raw.end()
} catch (streamError) {
logger.error({ error: streamError }, "Thinking streaming failed")
reply.raw.end()
}
} else {
const stream = await client.chatWithThinking(chatRequest)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama thinking chat request failed")
return reply.status(500).send({ error: "Thinking chat request failed" })
}
})
app.post('/api/ollama/chat/vision', {
schema: {
body: {
type: 'object',
required: ['model', 'messages', 'images'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
images: { type: 'array', items: { type: 'string' } },
stream: { type: 'boolean' }
}
}
}
}, async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const { model, messages, images, stream } = request.body as any
const chatRequest: ChatRequest = { model, messages, stream: stream ?? false }
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
try {
const streamResult = await client.chatWithVision(chatRequest, images)
for await (const chunk of streamResult) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
if (chunk.done) {
reply.raw.write('data: [DONE]\n\n')
break
}
}
reply.raw.end()
} catch (streamError) {
logger.error({ error: streamError }, "Vision streaming failed")
reply.raw.end()
}
} else {
const streamResult = await client.chatWithVision(chatRequest, images)
const chunks: any[] = []
for await (const chunk of streamResult) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama vision chat request failed")
return reply.status(500).send({ error: "Vision chat request failed" })
}
})
app.post('/api/ollama/chat/tools', {
schema: {
body: {
type: 'object',
required: ['model', 'messages', 'tools'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
tools: { type: 'array' },
stream: { type: 'boolean' }
}
}
}
}, async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const { model, messages, tools, stream } = request.body as any
const chatRequest: ChatRequest = { model, messages, stream: stream ?? false }
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
try {
const streamResult = await client.chatWithTools(chatRequest, tools)
for await (const chunk of streamResult) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
if (chunk.done) {
reply.raw.write('data: [DONE]\n\n')
break
}
}
reply.raw.end()
} catch (streamError) {
logger.error({ error: streamError }, "Tools streaming failed")
reply.raw.end()
}
} else {
const streamResult = await client.chatWithTools(chatRequest, tools)
const chunks: any[] = []
for await (const chunk of streamResult) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama tools chat request failed")
return reply.status(500).send({ error: "Tools chat request failed" })
}
})
app.post('/api/ollama/chat/websearch', {
schema: {
body: {
type: 'object',
required: ['model', 'messages'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
stream: { type: 'boolean' }
}
}
}
}, async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const chatRequest = request.body as ChatRequest
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
try {
const stream = await client.chatWithWebSearch(chatRequest)
for await (const chunk of stream) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
if (chunk.done) {
reply.raw.write('data: [DONE]\n\n')
break
}
}
reply.raw.end()
} catch (streamError) {
logger.error({ error: streamError }, "Web search streaming failed")
reply.raw.end()
}
} else {
const stream = await client.chatWithWebSearch(chatRequest)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama web search chat request failed")
return reply.status(500).send({ error: "Web search chat request failed" })
}
})
app.post('/api/ollama/embeddings', {
schema: {
body: {
type: 'object',
required: ['model', 'input'],
properties: {
model: { type: 'string' },
input: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }
}
}
}
}, async (request, reply) => {
try {
const config = getOllamaConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
const client = new OllamaCloudClient(config)
const embedRequest = request.body as EmbeddingRequest
const result = await client.generateEmbeddings(embedRequest)
return result
} catch (error) {
logger.error({ error }, "Ollama embeddings request failed")
return reply.status(500).send({ error: "Embeddings request failed" })
}
})
app.post('/api/ollama/pull', {
schema: {
body: {
type: 'object',
required: ['model'],
properties: {
model: { type: 'string' }
}
}
}
}, async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -192,7 +542,6 @@ export async function registerOllamaRoutes(
const client = new OllamaCloudClient(config)
const { model } = request.body as any
// Start async pull operation
client.pullModel(model).catch(error => {
logger.error({ error, model }, "Failed to pull model")
})
@@ -207,18 +556,36 @@ export async function registerOllamaRoutes(
logger.info("Ollama Cloud routes registered")
}
// Configuration management functions
function getOllamaConfig(): OllamaCloudConfig {
try {
const stored = localStorage.getItem('ollama_cloud_config')
return stored ? JSON.parse(stored) : { enabled: false, endpoint: "https://ollama.com" }
if (!fs.existsSync(CONFIG_FILE)) {
return { enabled: false, endpoint: "https://ollama.com" }
}
const data = fs.readFileSync(CONFIG_FILE, 'utf-8')
return JSON.parse(data)
} catch {
return { enabled: false, endpoint: "https://ollama.com" }
}
}
function updateOllamaConfig(config: Partial<OllamaCloudConfig>): void {
const current = getOllamaConfig()
const updated = { ...current, ...config }
localStorage.setItem('ollama_cloud_config', JSON.stringify(updated))
try {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true })
}
const current = getOllamaConfig()
// Only update apiKey if a new non-empty value is provided
const updated = {
...current,
...config,
// Preserve existing apiKey if new one is undefined/empty
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}`)
} catch (error) {
console.error("Failed to save Ollama config:", error)
}
}

View File

@@ -5,97 +5,168 @@ interface QwenRouteDeps {
logger: Logger
}
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56'
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion'
const QWEN_OAUTH_DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
const QWEN_DEFAULT_RESOURCE_URL = 'https://dashscope.aliyuncs.com/compatible-mode'
function normalizeQwenModel(model?: string): string {
const raw = (model || "").trim()
if (!raw) return "coder-model"
const lower = raw.toLowerCase()
if (lower === "vision-model" || lower.includes("vision")) return "vision-model"
if (lower === "coder-model") return "coder-model"
if (lower.includes("coder")) return "coder-model"
return "coder-model"
}
function normalizeQwenResourceUrl(resourceUrl?: string): string {
const raw = typeof resourceUrl === 'string' && resourceUrl.trim().length > 0
? resourceUrl.trim()
: QWEN_DEFAULT_RESOURCE_URL
const withProtocol = raw.startsWith('http') ? raw : `https://${raw}`
const trimmed = withProtocol.replace(/\/$/, '')
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`
}
export async function registerQwenRoutes(
app: FastifyInstance,
deps: QwenRouteDeps
) {
const logger = deps.logger.child({ component: "qwen-routes" })
// Get OAuth URL for Qwen authentication
app.get('/api/qwen/oauth/url', async (request, reply) => {
try {
const { clientId, redirectUri } = request.query as any
if (!clientId) {
return reply.status(400).send({ error: "Client ID is required" })
}
const authUrl = new URL('https://qwen.ai/oauth/authorize')
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', clientId)
authUrl.searchParams.set('redirect_uri', redirectUri || `${request.protocol}//${request.host}/auth/qwen/callback`)
authUrl.searchParams.set('scope', 'read write')
authUrl.searchParams.set('state', generateState())
return { authUrl: authUrl.toString() }
} catch (error) {
logger.error({ error }, "Failed to generate OAuth URL")
return reply.status(500).send({ error: "Failed to generate OAuth URL" })
}
})
// Exchange authorization code for token
app.post('/api/qwen/oauth/exchange', {
// Qwen OAuth Device Flow: request device authorization
app.post('/api/qwen/oauth/device', {
schema: {
body: {
type: 'object',
required: ['code', 'state'],
required: ['code_challenge', 'code_challenge_method'],
properties: {
code: { type: 'string' },
state: { type: 'string' },
client_id: { type: 'string' },
redirect_uri: { type: 'string' }
code_challenge: { type: 'string' },
code_challenge_method: { type: 'string' }
}
}
}
}, async (request, reply) => {
try {
const { code, state, client_id, redirect_uri } = request.body as any
// Exchange code for token with Qwen
const tokenResponse = await fetch('https://qwen.ai/oauth/token', {
const { code_challenge, code_challenge_method } = request.body as any
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: client_id,
code,
redirect_uri: redirect_uri
client_id: QWEN_OAUTH_CLIENT_ID,
scope: QWEN_OAUTH_SCOPE,
code_challenge,
code_challenge_method
})
})
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.statusText}`)
if (!response.ok) {
const errorText = await response.text()
logger.error({ status: response.status, errorText }, "Qwen device authorization failed")
return reply.status(response.status).send({ error: "Device authorization failed", details: errorText })
}
const tokenData = await tokenResponse.json()
const data = await response.json()
return { ...data }
} catch (error) {
logger.error({ error }, "Failed to request Qwen device authorization")
return reply.status(500).send({ error: "Device authorization failed" })
}
})
// Get user info
const userResponse = await fetch('https://qwen.ai/api/user', {
// Qwen OAuth Device Flow: poll token endpoint
app.post('/api/qwen/oauth/token', {
schema: {
body: {
type: 'object',
required: ['device_code', 'code_verifier'],
properties: {
device_code: { type: 'string' },
code_verifier: { type: 'string' }
}
}
}
}, async (request, reply) => {
try {
const { device_code, code_verifier } = request.body as any
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokenData.access_token}`
}
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: QWEN_OAUTH_DEVICE_GRANT_TYPE,
client_id: QWEN_OAUTH_CLIENT_ID,
device_code,
code_verifier
})
})
if (!userResponse.ok) {
throw new Error(`Failed to fetch user info: ${userResponse.statusText}`)
}
const userData = await userResponse.json()
return {
success: true,
user: userData,
token: {
access_token: tokenData.access_token,
token_type: tokenData.token_type,
expires_in: tokenData.expires_in,
scope: tokenData.scope
const responseText = await response.text()
if (!response.ok) {
logger.error({ status: response.status, responseText }, "Qwen device token poll failed")
return reply.status(response.status).send(responseText)
}
try {
return reply.send(JSON.parse(responseText))
} catch {
return reply.send(responseText)
}
} catch (error) {
logger.error({ error }, "Qwen OAuth token exchange failed")
return reply.status(500).send({ error: "OAuth exchange failed" })
logger.error({ error }, "Failed to poll Qwen token endpoint")
return reply.status(500).send({ error: "Token polling failed" })
}
})
// Qwen OAuth refresh token
app.post('/api/qwen/oauth/refresh', {
schema: {
body: {
type: 'object',
required: ['refresh_token'],
properties: {
refresh_token: { type: 'string' }
}
}
}
}, async (request, reply) => {
try {
const { refresh_token } = request.body as any
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token,
client_id: QWEN_OAUTH_CLIENT_ID
})
})
const responseText = await response.text()
if (!response.ok) {
logger.error({ status: response.status, responseText }, "Qwen token refresh failed")
return reply.status(response.status).send(responseText)
}
try {
return reply.send(JSON.parse(responseText))
} catch {
return reply.send(responseText)
}
} catch (error) {
logger.error({ error }, "Failed to refresh Qwen token")
return reply.status(500).send({ error: "Token refresh failed" })
}
})
@@ -108,7 +179,7 @@ export async function registerQwenRoutes(
}
const token = authHeader.substring(7)
const userResponse = await fetch('https://qwen.ai/api/user', {
const userResponse = await fetch('https://chat.qwen.ai/api/v1/user', {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -126,9 +197,121 @@ export async function registerQwenRoutes(
}
})
logger.info("Qwen OAuth routes registered")
// Qwen Chat API - proxy chat requests to Qwen using OAuth token
app.post('/api/qwen/chat', {
schema: {
body: {
type: 'object',
required: ['model', 'messages'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
stream: { type: 'boolean' },
resource_url: { type: 'string' }
}
}
}
}, async (request, reply) => {
try {
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return reply.status(401).send({ error: "Authorization required" })
}
function generateState(): string {
return Math.random().toString(36).substring(2, 15) + Date.now().toString(36)
const accessToken = authHeader.substring(7)
const { model, messages, stream, resource_url } = request.body as any
// Use resource_url from OAuth credentials to target the DashScope-compatible API
const apiBaseUrl = normalizeQwenResourceUrl(resource_url)
const normalizedModel = normalizeQwenModel(model)
const chatUrl = `${apiBaseUrl}/chat/completions`
logger.info({ chatUrl, model: normalizedModel, messageCount: messages?.length }, "Proxying Qwen chat request")
const response = await fetch(chatUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'Accept': stream ? 'text/event-stream' : 'application/json'
},
body: JSON.stringify({
model: normalizedModel,
messages,
stream: stream || false
})
})
if (!response.ok) {
const errorText = await response.text()
logger.error({ status: response.status, errorText }, "Qwen chat request failed")
return reply.status(response.status).send({ error: "Chat request failed", details: errorText })
}
if (stream && response.body) {
// Stream the response
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
reply.raw.write(chunk)
}
} finally {
reader.releaseLock()
reply.raw.end()
}
} else {
const data = await response.json()
return reply.send(data)
}
} catch (error) {
logger.error({ error }, "Qwen chat proxy failed")
return reply.status(500).send({ error: "Chat request failed" })
}
})
// Qwen Models list endpoint
app.get('/api/qwen/models', async (request, reply) => {
try {
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return reply.status(401).send({ error: "Authorization required" })
}
const accessToken = authHeader.substring(7)
const resourceUrl = (request.query as any).resource_url || 'https://chat.qwen.ai'
const modelsUrl = `${resourceUrl}/api/v1/models`
const response = await fetch(modelsUrl, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json'
}
})
if (!response.ok) {
const errorText = await response.text()
logger.error({ status: response.status, errorText }, "Qwen models request failed")
return reply.status(response.status).send({ error: "Models request failed", details: errorText })
}
const data = await response.json()
return reply.send(data)
} catch (error) {
logger.error({ error }, "Qwen models request failed")
return reply.status(500).send({ error: "Models request failed" })
}
})
logger.info("Qwen OAuth routes registered")
}

View File

@@ -24,12 +24,29 @@ 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(),
sessionSkills: z
.record(
z.string(),
z.array(z.object({ id: z.string(), name: z.string(), description: z.string().optional() })),
)
.optional(),
customAgents: z
.array(
z.object({
name: z.string(),
description: z.string().optional(),
prompt: z.string(),
}),
)
.optional(),
})
const EMPTY_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
sessionTasks: {},
sessionSkills: {},
customAgents: [],
}
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {

View File

@@ -1,10 +1,18 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { spawnSync } from "child_process"
import { z } from "zod"
import { existsSync, mkdirSync } from "fs"
import { cp, readFile, writeFile } from "fs/promises"
import path from "path"
import { WorkspaceManager } from "../../workspaces/manager"
import { InstanceStore } from "../../storage/instance-store"
import { ConfigStore } from "../../config/store"
import { getWorkspaceOpencodeConfigDir } from "../../opencode-config"
interface RouteDeps {
workspaceManager: WorkspaceManager
instanceStore: InstanceStore
configStore: ConfigStore
}
const WorkspaceCreateSchema = z.object({
@@ -163,6 +171,143 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return { isRepo: true, branch, ahead, behind, changes }
})
app.post<{
Params: { id: string }
Body: { destination: string; includeConfig?: boolean }
}>("/api/workspaces/:id/export", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const payload = request.body ?? { destination: "" }
const destination = payload.destination?.trim()
if (!destination) {
reply.code(400)
return { error: "Destination is required" }
}
const exportRoot = path.join(destination, `nomadarch-export-${path.basename(workspace.path)}-${Date.now()}`)
mkdirSync(exportRoot, { recursive: true })
const workspaceTarget = path.join(exportRoot, "workspace")
await cp(workspace.path, workspaceTarget, { recursive: true, force: true })
const instanceData = await deps.instanceStore.read(workspace.path)
await writeFile(path.join(exportRoot, "instance-data.json"), JSON.stringify(instanceData, null, 2), "utf-8")
const configDir = getWorkspaceOpencodeConfigDir(workspace.id)
if (existsSync(configDir)) {
await cp(configDir, path.join(exportRoot, "opencode-config"), { recursive: true, force: true })
}
if (payload.includeConfig) {
const config = deps.configStore.get()
await writeFile(path.join(exportRoot, "user-config.json"), JSON.stringify(config, null, 2), "utf-8")
}
const metadata = {
exportedAt: new Date().toISOString(),
workspacePath: workspace.path,
workspaceId: workspace.id,
}
await writeFile(path.join(exportRoot, "metadata.json"), JSON.stringify(metadata, null, 2), "utf-8")
return { destination: exportRoot }
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id/mcp-config", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const configPath = path.join(workspace.path, ".mcp.json")
if (!existsSync(configPath)) {
return { path: configPath, exists: false, config: { mcpServers: {} } }
}
try {
const raw = await readFile(configPath, "utf-8")
const parsed = raw ? JSON.parse(raw) : {}
return { path: configPath, exists: true, config: parsed }
} catch (error) {
request.log.error({ err: error }, "Failed to read MCP config")
reply.code(500)
return { error: "Failed to read MCP config" }
}
})
app.put<{ Params: { id: string } }>("/api/workspaces/:id/mcp-config", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const body = request.body as { config?: unknown }
if (!body || typeof body.config !== "object" || body.config === null) {
reply.code(400)
return { error: "Invalid MCP config payload" }
}
const configPath = path.join(workspace.path, ".mcp.json")
try {
await writeFile(configPath, JSON.stringify(body.config, null, 2), "utf-8")
return { path: configPath, exists: true, config: body.config }
} catch (error) {
request.log.error({ err: error }, "Failed to write MCP config")
reply.code(500)
return { error: "Failed to write MCP config" }
}
})
app.post<{
Body: { source: string; destination: string; includeConfig?: boolean }
}>("/api/workspaces/import", async (request, reply) => {
const payload = request.body ?? { source: "", destination: "" }
const source = payload.source?.trim()
const destination = payload.destination?.trim()
if (!source || !destination) {
reply.code(400)
return { error: "Source and destination are required" }
}
const workspaceSource = path.join(source, "workspace")
if (!existsSync(workspaceSource)) {
reply.code(400)
return { error: "Export workspace folder not found" }
}
await cp(workspaceSource, destination, { recursive: true, force: true })
const workspace = await deps.workspaceManager.create(destination)
const instanceDataPath = path.join(source, "instance-data.json")
if (existsSync(instanceDataPath)) {
const raw = await readFile(instanceDataPath, "utf-8")
await deps.instanceStore.write(workspace.path, JSON.parse(raw))
}
const configSource = path.join(source, "opencode-config")
if (existsSync(configSource)) {
const configTarget = getWorkspaceOpencodeConfigDir(workspace.id)
await cp(configSource, configTarget, { recursive: true, force: true })
}
if (payload.includeConfig) {
const userConfigPath = path.join(source, "user-config.json")
if (existsSync(userConfigPath)) {
const raw = await readFile(userConfigPath, "utf-8")
deps.configStore.replace(JSON.parse(raw))
}
}
return workspace
})
}

View File

@@ -1,16 +1,15 @@
import { FastifyInstance } from "fastify"
import { ZAIClient, type ZAIConfig, type ZAIChatRequest } from "../../integrations/zai-api"
import { ZAIClient, ZAI_MODELS, type ZAIConfig, type ZAIChatRequest, ZAIChatRequestSchema } from "../../integrations/zai-api"
import { Logger } from "../../logger"
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
import { join } from "path"
import { homedir } from "os"
import { getUserIntegrationsDir } from "../../user-data"
interface ZAIRouteDeps {
logger: Logger
}
// Config file path
const CONFIG_DIR = join(homedir(), ".nomadarch")
const CONFIG_DIR = getUserIntegrationsDir()
const CONFIG_FILE = join(CONFIG_DIR, "zai-config.json")
export async function registerZAIRoutes(
@@ -69,15 +68,7 @@ export async function registerZAIRoutes(
// List available models
app.get('/api/zai/models', async (request, reply) => {
try {
const config = getZAIConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Z.AI is not enabled" })
}
const client = new ZAIClient(config)
const models = await client.listModels()
return { models: models.map(name => ({ name, provider: "zai" })) }
return { models: ZAI_MODELS.map(name => ({ name, provider: "zai" })) }
} catch (error) {
logger.error({ error }, "Failed to list Z.AI models")
return reply.status(500).send({ error: "Failed to list models" })
@@ -107,8 +98,9 @@ export async function registerZAIRoutes(
for await (const chunk of client.chatStream(chatRequest)) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
// Check for message_stop event
if (chunk.type === "message_stop") {
// Check for finish_reason to end stream
const finishReason = chunk.choices[0]?.finish_reason
if (finishReason) {
reply.raw.write('data: [DONE]\n\n')
break
}
@@ -133,16 +125,15 @@ export async function registerZAIRoutes(
logger.info("Z.AI routes registered")
}
// Configuration management functions using file-based storage
function getZAIConfig(): ZAIConfig {
try {
if (existsSync(CONFIG_FILE)) {
const data = readFileSync(CONFIG_FILE, 'utf-8')
return JSON.parse(data)
}
return { enabled: false, endpoint: "https://api.z.ai/api/anthropic", timeout: 3000000 }
return { enabled: false, endpoint: "https://api.z.ai/api/paas/v4", timeout: 300000 }
} catch {
return { enabled: false, endpoint: "https://api.z.ai/api/anthropic", timeout: 3000000 }
return { enabled: false, endpoint: "https://api.z.ai/api/paas/v4", timeout: 300000 }
}
}

View File

@@ -1,8 +1,8 @@
import fs from "fs"
import { promises as fsp } from "fs"
import os from "os"
import path from "path"
import type { InstanceData } from "../api-types"
import { getUserInstancesDir } from "../user-data"
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
@@ -13,7 +13,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
export class InstanceStore {
private readonly instancesDir: string
constructor(baseDir = path.join(os.homedir(), ".config", "codenomad", "instances")) {
constructor(baseDir = getUserInstancesDir()) {
this.instancesDir = baseDir
fs.mkdirSync(this.instancesDir, { recursive: true })
}

View File

@@ -0,0 +1,28 @@
import os from "os"
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
}
export function getUserConfigPath(): string {
return path.join(getUserDataRoot(), "config.json")
}
export function getUserInstancesDir(): string {
return path.join(getUserDataRoot(), "instances")
}
export function getUserIntegrationsDir(): string {
return path.join(getUserDataRoot(), "integrations")
}
export function getOpencodeWorkspacesRoot(): string {
return path.join(getUserDataRoot(), "opencode-workspaces")
}

View File

@@ -0,0 +1,35 @@
import net from "net"
const DEFAULT_START_PORT = 3000
const MAX_PORT_ATTEMPTS = 50
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
server.once("error", () => {
resolve(false)
})
server.once("listening", () => {
server.close()
resolve(true)
})
server.listen(port, "127.0.0.1")
})
}
export async function findAvailablePort(startPort: number = DEFAULT_START_PORT): Promise<number> {
for (let port = startPort; port < startPort + MAX_PORT_ATTEMPTS; port++) {
if (await isPortAvailable(port)) {
return port
}
}
return 0
}
export async function getAvailablePort(preferredPort: number = DEFAULT_START_PORT): Promise<number> {
const isAvailable = await isPortAvailable(preferredPort)
if (isAvailable) {
return preferredPort
}
return findAvailablePort(preferredPort + 1)
}

View File

@@ -10,7 +10,7 @@ import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config"
import { ensureWorkspaceOpencodeConfig } from "../opencode-config"
const STARTUP_STABILITY_DELAY_MS = 1500
@@ -27,11 +27,9 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
list(): WorkspaceDescriptor[] {
@@ -105,9 +103,10 @@ export class WorkspaceManager {
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const opencodeConfigDir = ensureWorkspaceOpencodeConfig(id)
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
OPENCODE_CONFIG_DIR: opencodeConfigDir,
}
try {

View File

@@ -7,7 +7,8 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json"
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "node --test --experimental-strip-types src/lib/__tests__/*.test.ts src/stores/__tests__/*.test.ts"
},
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
@@ -30,8 +31,10 @@
"autoprefixer": "10.4.21",
"postcss": "8.5.6",
"tailwindcss": "3",
"tsx": "^4.21.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
"vite-plugin-solid": "^2.10.0",
"zod": "^3.25.76"
}
}

View File

@@ -24,6 +24,8 @@ import {
setIsSelectingFolder,
showFolderSelection,
setShowFolderSelection,
showFolderSelectionOnStart,
setShowFolderSelectionOnStart,
} from "./stores/ui"
import { useConfig } from "./stores/preferences"
import {
@@ -74,6 +76,8 @@ const App: Component = () => {
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const shouldShowFolderSelection = () => !hasInstances() || showFolderSelectionOnStart()
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
@@ -156,6 +160,7 @@ const App: Component = () => {
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setShowFolderSelection(false)
setShowFolderSelectionOnStart(false)
setIsAdvancedSettingsOpen(false)
log.info("Created instance", {
@@ -375,7 +380,7 @@ const App: Component = () => {
</Dialog>
<div class="h-screen w-screen flex flex-col">
<Show
when={!hasInstances()}
when={shouldShowFolderSelection()}
fallback={
<>
<InstanceTabs
@@ -432,6 +437,7 @@ const App: Component = () => {
<button
onClick={() => {
setShowFolderSelection(false)
setShowFolderSelectionOnStart(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}

View File

@@ -1,13 +1,15 @@
import { createSignal, Show, onMount, For, createMemo, createEffect } from "solid-js";
import { createSignal, Show, onMount, For, createMemo, createEffect, onCleanup } from "solid-js";
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
import { instances } from "@/stores/instances";
import { sendMessage } from "@/stores/session-actions";
import { addTask, setActiveTask } from "@/stores/task-actions";
import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession } from "@/stores/session-actions";
import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions";
import { messageStoreBus } from "@/stores/message-v2/bus";
import MessageBlockList from "@/components/message-block-list";
import MessageBlockList, { getMessageAnchorId } from "@/components/message-block-list";
import { formatTokenTotal } from "@/lib/formatters";
import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval, toggleApex } from "@/stores/solo-store";
import { getLogger } from "@/lib/logger";
import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction";
import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events";
import {
Command,
Plus,
@@ -35,10 +37,18 @@ import {
User,
Settings,
Key,
FileArchive,
Paperclip,
} from "lucide-solid";
import ModelSelector from "@/components/model-selector";
import AgentSelector from "@/components/agent-selector";
import AttachmentChip from "@/components/attachment-chip";
import { createFileAttachment } from "@/types/attachment";
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
import type { Task } from "@/types/session";
const OPEN_ADVANCED_SETTINGS_EVENT = "open-advanced-settings";
const log = getLogger("multix-chat");
interface MultiTaskChatProps {
@@ -51,17 +61,29 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
const [isSending, setIsSending] = createSignal(false);
const [chatInput, setChatInput] = createSignal("");
const [isCompacting, setIsCompacting] = createSignal(false);
const [attachments, setAttachments] = createSignal<ReturnType<typeof createFileAttachment>[]>([]);
let scrollContainer: HTMLDivElement | undefined;
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
const [showApiManager, setShowApiManager] = createSignal(false);
const [userScrolling, setUserScrolling] = createSignal(false);
const [lastScrollTop, setLastScrollTop] = createSignal(0);
let fileInputRef: HTMLInputElement | undefined;
// Scroll to bottom helper
const scrollToBottom = () => {
if (scrollContainer) {
if (scrollContainer && !userScrolling()) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
};
// Track if user is manually scrolling (not at bottom)
const checkUserScrolling = () => {
if (!scrollContainer) return false;
const threshold = 50;
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < threshold;
return !isAtBottom;
};
// Get current session and tasks
const session = () => {
const instanceSessions = sessions().get(props.instanceId);
@@ -69,7 +91,8 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
};
const tasks = () => session()?.tasks || [];
const selectedTask = () => tasks().find(t => t.id === selectedTaskId());
const visibleTasks = createMemo(() => tasks().filter((task) => !task.archived));
const selectedTask = () => visibleTasks().find((task) => task.id === selectedTaskId());
// Message store integration
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
@@ -114,19 +137,20 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
return {
used: usage?.actualUsageTokens ?? 0,
total: usage?.totalCost ?? 0,
input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0,
reasoning: usage?.reasoningTokens ?? 0,
cacheRead: usage?.cacheReadTokens ?? 0,
cacheWrite: usage?.cacheWriteTokens ?? 0,
// input: usage?.inputTokens ?? 0,
// output: usage?.outputTokens ?? 0,
// reasoning: usage?.reasoningTokens ?? 0,
// cacheRead: usage?.cacheReadTokens ?? 0,
// cacheWrite: usage?.cacheWriteTokens ?? 0,
cost: usage?.totalCost ?? 0,
};
});
// Get current model from instance
// Get current model from active task session
const currentModel = createMemo(() => {
const instance = instances().get(props.instanceId);
return instance?.modelId || "unknown";
const instanceSessions = sessions().get(props.instanceId);
const session = instanceSessions?.get(activeTaskSessionId());
return session?.model?.modelId || "unknown";
});
const activeTaskSessionId = createMemo(() => {
@@ -134,6 +158,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
return task?.taskSessionId || props.sessionId;
});
const activeTaskSession = createMemo(() => {
const instanceSessions = sessions().get(props.instanceId);
return instanceSessions?.get(activeTaskSessionId());
});
const currentTaskAgent = createMemo(() => activeTaskSession()?.agent || "");
const currentTaskModel = createMemo(() => activeTaskSession()?.model || { providerId: "", modelId: "" });
const compactionSuggestion = createMemo(() => {
const sessionId = activeTaskSessionId();
return getCompactionSuggestion(props.instanceId, sessionId);
});
const hasCompactionSuggestion = createMemo(() => Boolean(compactionSuggestion()));
const solo = () => getSoloState(props.instanceId);
// APEX PRO mode = SOLO + APEX combined (autonomous + auto-approval)
@@ -181,8 +220,12 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const streaming = isAgentThinking();
if (!streaming) return;
// During streaming, scroll periodically to keep up with content
const interval = setInterval(scrollToBottom, 300);
// During streaming, scroll periodically to keep up with content (unless user is scrolling)
const interval = setInterval(() => {
if (!userScrolling()) {
scrollToBottom();
}
}, 300);
return () => clearInterval(interval);
});
@@ -191,14 +234,40 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const ids = filteredMessageIds();
const thinking = isAgentThinking();
// Scroll when message count changes or when thinking starts
if (ids.length > 0 || thinking) {
// Scroll when message count changes or when thinking starts (unless user is scrolling)
if ((ids.length > 0 || thinking) && !userScrolling()) {
requestAnimationFrame(() => {
setTimeout(scrollToBottom, 50);
});
}
});
// Scroll event listener to detect user scrolling
onMount(() => {
const handleScroll = () => {
if (scrollContainer) {
const isScrollingUp = scrollContainer.scrollTop < lastScrollTop();
const isScrollingDown = scrollContainer.scrollTop > lastScrollTop();
setLastScrollTop(scrollContainer.scrollTop);
// If user scrolls up or scrolls away from bottom, set userScrolling flag
if (checkUserScrolling()) {
setUserScrolling(true);
} else {
// User is back at bottom, reset the flag
setUserScrolling(false);
}
}
};
const container = scrollContainer;
container?.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container?.removeEventListener('scroll', handleScroll);
};
});
const handleSendMessage = async () => {
const message = chatInput().trim();
if (!message || isSending()) return;
@@ -253,12 +322,13 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
props.instanceId,
targetSessionId,
message,
[],
attachments(),
taskId || undefined
);
log.info("sendMessage call completed");
setChatInput("");
setAttachments([]);
// Auto-scroll to bottom after sending
setTimeout(scrollToBottom, 100);
@@ -271,6 +341,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
}
};
const handleCreateTask = async () => {
if (isSending()) return;
setChatInput("");
try {
const nextIndex = tasks().length + 1;
const title = `Task ${nextIndex}`;
const result = await addTask(props.instanceId, props.sessionId, title);
setSelectedTaskId(result.id);
setTimeout(scrollToBottom, 50);
} catch (error) {
log.error("handleCreateTask failed", error);
console.error("[MultiTaskChat] Task creation failed:", error);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
// Enter to submit, Shift+Enter for new line
if (e.key === "Enter" && !e.shiftKey) {
@@ -298,8 +383,64 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
}
};
const handleOpenAdvancedSettings = () => {
// Dispatch custom event to trigger Advanced Settings modal from parent
window.dispatchEvent(new CustomEvent(OPEN_ADVANCED_SETTINGS_EVENT, {
detail: { instanceId: props.instanceId, sessionId: props.sessionId }
}));
};
const handleCompact = async () => {
const targetSessionId = activeTaskSessionId();
if (isCompacting()) return;
setIsCompacting(true);
log.info("Compacting session", { instanceId: props.instanceId, sessionId: targetSessionId });
try {
clearCompactionSuggestion(props.instanceId, targetSessionId);
await compactSession(props.instanceId, targetSessionId);
log.info("Session compacted successfully");
} catch (error) {
log.error("Failed to compact session", error);
console.error("[MultiTaskChat] Compact failed:", error);
} finally {
setIsCompacting(false);
log.info("Compact operation finished");
}
};
const addAttachment = (attachment: ReturnType<typeof createFileAttachment>) => {
setAttachments((prev) => [...prev, attachment]);
};
const removeAttachment = (attachmentId: string) => {
setAttachments((prev) => prev.filter((item) => item.id !== attachmentId));
};
const handleFileSelect = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
Array.from(input.files).forEach((file) => {
const reader = new FileReader();
reader.onload = () => {
const buffer = reader.result instanceof ArrayBuffer ? reader.result : null;
const data = buffer ? new Uint8Array(buffer) : undefined;
const attachment = createFileAttachment(file.name, file.name, file.type || "application/octet-stream", data);
if (file.type.startsWith("image/") && typeof reader.result === "string") {
attachment.url = reader.result;
}
addAttachment(attachment);
};
reader.readAsArrayBuffer(file);
});
input.value = "";
};
return (
<main class="h-full max-h-full flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
<main class="absolute inset-0 flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
{/* Header */}
<header class="h-14 px-4 flex items-center justify-between bg-zinc-900/60 backdrop-blur-xl border-b border-white/5 relative z-30 shrink-0">
<div class="flex items-center space-x-3">
@@ -309,6 +450,14 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<Zap size={10} class="text-white fill-current" />
</div>
</div>
<button
onClick={() => emitSessionSidebarRequest({ instanceId: props.instanceId, action: "show-skills" })}
class="flex items-center space-x-1.5 px-2.5 py-1.5 rounded-lg border border-white/10 bg-white/5 text-zinc-400 hover:text-indigo-300 hover:border-indigo-500/30 hover:bg-indigo-500/10 transition-all"
title="Open Skills"
>
<Sparkles size={12} class="text-indigo-400" />
<span class="text-[10px] font-black uppercase tracking-tight">Skills</span>
</button>
<Show when={selectedTaskId()}>
<div class="flex items-center space-x-2 animate-in fade-in slide-in-from-left-2 duration-300">
@@ -351,9 +500,25 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</div>
</Show>
{/* API Key Manager Button */}
{/* Compact Button - Context Compression & Summary */}
<button
onClick={() => setShowApiManager(true)}
onClick={handleCompact}
class={`flex items-center space-x-1.5 px-2.5 py-1.5 transition-all rounded-xl active:scale-95 border ${isCompacting()
? "text-blue-400 bg-blue-500/15 border-blue-500/40 animate-pulse shadow-[0_0_20px_rgba(59,130,246,0.3)]"
: hasCompactionSuggestion()
? "text-emerald-300 bg-emerald-500/20 border-emerald-500/50 shadow-[0_0_16px_rgba(34,197,94,0.35)] animate-pulse"
: "text-zinc-500 hover:text-blue-400 hover:bg-blue-500/10 border-transparent hover:border-blue-500/30"
}`}
title={isCompacting() ? "Compacting session (compressing context & creating summary)..." : "Compact session - Compress context & create summary"}
disabled={isCompacting()}
>
<FileArchive size={16} strokeWidth={2} />
<span class="text-[10px] font-bold uppercase tracking-tight">{isCompacting() ? "Compacting..." : "Compact"}</span>
</button>
{/* API Key Manager Button - Opens Advanced Settings */}
<button
onClick={handleOpenAdvancedSettings}
class="p-2 text-zinc-500 hover:text-emerald-400 transition-all hover:bg-emerald-500/10 rounded-xl active:scale-90"
title="API Key Manager"
>
@@ -369,7 +534,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</header>
{/* Task Tabs (Horizontal Scroll) */}
<Show when={tasks().length > 0}>
<Show when={visibleTasks().length > 0}>
<div class="flex items-center bg-[#0a0a0b] border-b border-white/5 px-2 py-2 space-x-1.5 overflow-x-auto custom-scrollbar-hidden no-scrollbar shrink-0">
<button
onClick={() => setSelectedTaskId(null)}
@@ -385,7 +550,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<div class="w-px h-4 bg-white/10 shrink-0 mx-0.5" />
<div class="flex items-center space-x-1.5 overflow-x-auto no-scrollbar">
<For each={tasks()}>
<For each={visibleTasks()}>
{(task) => (
<button
onClick={() => setSelectedTaskId(task.id)}
@@ -399,6 +564,18 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
"bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.4)] animate-pulse"
}`} />
<span class="truncate">{task.title}</span>
<span
role="button"
tabindex={0}
onClick={(event) => {
event.stopPropagation();
archiveTask(props.instanceId, props.sessionId, task.id);
}}
class="opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-200 transition-colors"
title="Archive task"
>
<X size={12} />
</span>
<Show when={selectedTaskId() === task.id}>
<div class="ml-1 w-1 h-1 bg-indigo-400 rounded-full animate-ping" />
</Show>
@@ -409,8 +586,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<button
onClick={() => {
setChatInput("");
setSelectedTaskId(null);
handleCreateTask();
}}
class="flex items-center justify-center w-8 h-8 rounded-xl text-zinc-500 hover:text-indigo-400 hover:bg-indigo-500/10 transition-all shrink-0 ml-1 border border-transparent hover:border-indigo-500/20"
title="New Task"
@@ -420,6 +596,25 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</div>
</Show>
<Show when={selectedTask()}>
<div class="px-4 py-3 border-b border-white/5 bg-zinc-950/40">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<AgentSelector
instanceId={props.instanceId}
sessionId={activeTaskSessionId()}
currentAgent={currentTaskAgent()}
onAgentChange={(agent) => updateSessionAgent(props.instanceId, activeTaskSessionId(), agent)}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={activeTaskSessionId()}
currentModel={currentTaskModel()}
onModelChange={(model) => updateSessionModelForSession(props.instanceId, activeTaskSessionId(), model)}
/>
</div>
</div>
</Show>
{/* Main Content Area - min-h-0 is critical for flex containers with overflow */}
<div class="flex-1 min-h-0 relative overflow-hidden flex">
{/* Main chat area */}
@@ -428,6 +623,18 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
ref={scrollContainer}
class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar"
>
<Show when={hasCompactionSuggestion()}>
<div class="mx-3 mt-3 mb-1 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-[11px] text-emerald-200 flex items-center justify-between gap-3">
<span class="font-semibold">Compact suggested: {compactionSuggestion()?.reason}</span>
<button
type="button"
class="px-2.5 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wide bg-emerald-500/20 border border-emerald-500/40 text-emerald-200 hover:bg-emerald-500/30 transition-colors"
onClick={handleCompact}
>
Compact now
</button>
</div>
</Show>
<Show when={!selectedTaskId()} fallback={
<div class="p-3 pb-4 overflow-x-hidden">
<MessageBlockList
@@ -456,12 +663,12 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">Active Threads</span>
<div class="h-px flex-1 bg-white/5 mx-4" />
<span class="text-[10px] font-black text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded border border-indigo-500/20">
{tasks().length}
{visibleTasks().length}
</span>
</div>
<div class="grid gap-3">
<For each={tasks()} fallback={
<For each={visibleTasks()} fallback={
<div class="group relative p-8 rounded-3xl border border-dashed border-white/5 bg-zinc-900/20 flex flex-col items-center justify-center text-center space-y-4 transition-all hover:bg-zinc-900/40 hover:border-white/10">
<div class="w-12 h-12 rounded-2xl bg-white/5 flex items-center justify-center text-zinc-600 group-hover:text-indigo-400 group-hover:scale-110 transition-all duration-500">
<Plus size={24} strokeWidth={1.5} />
@@ -491,7 +698,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span>{task.messageIds?.length || 0} messages</span>
</div>
</div>
<div class="flex items-center space-x-2">
<span
role="button"
tabindex={0}
onClick={(event) => {
event.stopPropagation();
archiveTask(props.instanceId, props.sessionId, task.id);
}}
class="text-zinc-600 hover:text-zinc-200 transition-colors"
title="Archive task"
>
<X size={14} />
</span>
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
</div>
</button>
)}
</For>
@@ -572,19 +793,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "THINKING" : "SENDING"}</span>
</div>
</Show>
{/* STOP button */}
<Show when={isAgentThinking()}>
<button
onClick={handleStopAgent}
class="flex items-center space-x-1 px-2 py-0.5 bg-rose-500/20 hover:bg-rose-500/30 rounded border border-rose-500/40 text-[9px] font-bold text-rose-400 transition-all"
title="Stop agent"
>
<StopCircle size={10} />
<span>STOP</span>
</button>
</div>
</div>
<Show when={attachments().length > 0}>
<div class="flex flex-wrap gap-2 mb-2">
<For each={attachments()}>
{(attachment) => (
<AttachmentChip
attachment={attachment}
onRemove={() => removeAttachment(attachment.id)}
/>
)}
</For>
</div>
</Show>
</div>
</div>
{/* Text Input */}
<textarea
@@ -601,38 +824,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<div class="flex items-center justify-between pt-2 border-t border-white/5 mt-2">
<div class="flex items-center space-x-2 flex-wrap gap-y-1">
{/* Detailed token stats */}
<Show when={tokenStats().input > 0 || tokenStats().output > 0}>
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">INPUT</span>
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().input.toLocaleString()}</span>
</div>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">OUTPUT</span>
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().output.toLocaleString()}</span>
</div>
<Show when={tokenStats().reasoning > 0}>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">REASONING</span>
<span class="text-[9px] font-bold text-amber-400">{tokenStats().reasoning.toLocaleString()}</span>
</div>
</Show>
<Show when={tokenStats().cacheRead > 0}>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE READ</span>
<span class="text-[9px] font-bold text-emerald-400">{tokenStats().cacheRead.toLocaleString()}</span>
</div>
</Show>
<Show when={tokenStats().cacheWrite > 0}>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE WRITE</span>
<span class="text-[9px] font-bold text-cyan-400">{tokenStats().cacheWrite.toLocaleString()}</span>
</div>
</Show>
<div class="w-px h-3 bg-zinc-800" />
{/* Detailed breakdown not available */}
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">COST</span>
<span class="text-[9px] font-bold text-violet-400">${tokenStats().cost.toFixed(4)}</span>
@@ -642,8 +834,22 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
</div>
</Show>
<Show when={!(tokenStats().input > 0 || tokenStats().output > 0)}>
<div class="flex items-center space-x-1.5">
<input
ref={fileInputRef}
type="file"
multiple
class="sr-only"
onChange={handleFileSelect}
/>
<button
type="button"
onClick={() => fileInputRef?.click()}
class="text-zinc-600 hover:text-indigo-300 transition-colors p-1"
title="Attach files"
>
<Paperclip size={14} />
</button>
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
<Hash size={14} />
</button>
@@ -655,9 +861,20 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<kbd class="px-1.5 py-0.5 bg-zinc-800 rounded text-[9px] font-bold border border-white/5">ENTER</kbd>
<span class="text-[9px]">to send</span>
</div>
</Show>
</div>
</div>
<div class="flex items-center space-x-2">
<Show when={isAgentThinking() || isSending()}>
<button
onClick={handleStopAgent}
class="px-3 py-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 rounded-lg text-[10px] font-bold uppercase tracking-wide transition-all border border-rose-500/30"
title="Stop response"
>
<StopCircle size={12} class="inline-block mr-1" />
Stop
</button>
</Show>
<button
onClick={handleSendMessage}
disabled={!chatInput().trim() || isSending()}
@@ -676,33 +893,41 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</div>
</div>
</div>
</div>
{/* Message Navigation Sidebar - YOU/ASST labels with hover preview */}
<Show when={selectedTaskId() && filteredMessageIds().length > 0}>
<div class="w-14 shrink-0 bg-zinc-900/40 border-l border-white/5 overflow-y-auto py-2 px-1.5 flex flex-col items-center gap-1">
<div class="w-14 shrink-0 bg-zinc-900/40 border-l border-white/5 overflow-hidden py-2 px-1.5 flex flex-col items-center gap-1">
<For each={filteredMessageIds()}>
{(messageId, index) => {
const msg = () => messageStore().getMessage(messageId);
const isUser = () => msg()?.role === "user";
const [showPreview, setShowPreview] = createSignal(false);
// Get message preview text (first 100 chars)
// Get message preview text (first 150 chars)
const previewText = () => {
const message = msg();
if (!message) return "";
const content = message.parts?.[0]?.content || message.content || "";
const content = (message.parts?.[0] as any)?.text || (message.parts?.[0] as any)?.content || (message as any).content || "";
const text = typeof content === "string" ? content : JSON.stringify(content);
return text.length > 100 ? text.substring(0, 100) + "..." : text;
return text.length > 150 ? text.substring(0, 150) + "..." : text;
};
const handleTabClick = () => {
const anchorId = getMessageAnchorId(messageId);
const element = scrollContainer?.querySelector(`#${anchorId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
// Highlight the message briefly
element.classList.add("message-highlight");
setTimeout(() => element.classList.remove("message-highlight"), 2000);
}
};
return (
<div class="relative group">
<button
onClick={() => {
// Scroll to message
const element = document.getElementById(`msg-${messageId}`);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
}}
onClick={handleTabClick}
onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)}
class={`w-10 py-1.5 rounded text-[8px] font-black uppercase transition-all cursor-pointer ${isUser()
@@ -715,11 +940,16 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
{/* Hover Preview Tooltip */}
<Show when={showPreview()}>
<div class="absolute right-full mr-2 top-0 w-64 max-h-32 overflow-hidden bg-zinc-900 border border-white/10 rounded-lg shadow-xl p-2 z-50 animate-in fade-in slide-in-from-right-2 duration-150">
<div class={`text-[9px] font-bold uppercase mb-1 ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
{isUser() ? "You" : "Assistant"} Message {index() + 1}
<div class="absolute right-full mr-2 top-0 w-72 max-h-40 overflow-y-auto bg-zinc-900 border border-white/10 rounded-lg shadow-xl p-3 z-50 animate-in fade-in slide-in-from-right-2 duration-150 custom-scrollbar">
<div class="flex items-center justify-between mb-2">
<div class={`text-[9px] font-bold uppercase ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
{isUser() ? "You" : "Assistant"} Msg {index() + 1}
</div>
<p class="text-[11px] text-zinc-300 leading-relaxed line-clamp-4">
<div class="text-[8px] text-zinc-600">
{msg()?.status === "streaming" ? "• Streaming" : ""}
</div>
</div>
<p class="text-[10px] text-zinc-300 leading-relaxed whitespace-pre-wrap">
{previewText()}
</p>
</div>
@@ -732,79 +962,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</Show>
</div>
{/* API Key Manager Modal */}
<Show when={showApiManager()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowApiManager(false)}>
<div class="w-full max-w-2xl bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()}>
<header class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center">
<Key size={20} class="text-white" />
</div>
<div>
<h2 class="text-lg font-bold text-white">API Key Manager</h2>
<p class="text-xs text-zinc-500">Manage your access tokens for various AI providers</p>
</div>
</div>
<button onClick={() => setShowApiManager(false)} class="p-2 hover:bg-white/10 rounded-lg transition-colors">
<X size={20} class="text-zinc-400" />
</button>
</header>
<div class="flex h-[400px]">
{/* Sidebar */}
<div class="w-48 bg-zinc-950/50 border-r border-white/5 p-3 space-y-1">
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest px-2 py-1">Built-in</div>
<button class="w-full text-left px-3 py-2 rounded-lg bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 text-sm font-medium">
NomadArch (Free)
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
Ollama Cloud
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
OpenAI
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
Anthropic
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
OpenRouter
</button>
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest px-2 py-1 mt-4">Custom</div>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors flex items-center space-x-2">
<Plus size={14} />
<span>Add Custom Provider</span>
</button>
</div>
{/* Content */}
<div class="flex-1 p-6 flex flex-col items-center justify-center">
<div class="w-16 h-16 rounded-2xl bg-emerald-500/20 flex items-center justify-center mb-4">
<Shield size={32} class="text-emerald-400" />
</div>
<h3 class="text-xl font-bold text-white mb-2">NomadArch Managed Models</h3>
<p class="text-sm text-zinc-400 text-center max-w-sm mb-6">
These models are provided free of charge as part of the NomadArch platform. No API key or configuration is required to use them.
</p>
<div class="bg-zinc-800/50 rounded-xl p-4 w-full max-w-sm space-y-3">
<div class="flex justify-between text-sm">
<span class="text-zinc-500">Providers</span>
<span class="text-white font-medium">Qwen, DeepSeek, Google</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-500">Rate Limit</span>
<span class="text-white font-medium">Generous / Unlimited</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-500">Status</span>
<span class="text-emerald-400 font-bold">ACTIVE</span>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
</main >
);
}

View File

@@ -5,8 +5,9 @@ import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { users, activeUser, refreshUsers, createUser, updateUser, deleteUser, loginUser, createGuest } from "../stores/users"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const nomadArchLogo = new URL("../images/NomadArch-Icon.png", import.meta.url).href
interface FolderSelectionViewProps {
@@ -24,6 +25,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const [showUserModal, setShowUserModal] = createSignal(false)
const [newUserName, setNewUserName] = createSignal("")
const [newUserPassword, setNewUserPassword] = createSignal("")
const [loginPassword, setLoginPassword] = createSignal("")
const [loginTargetId, setLoginTargetId] = createSignal<string | null>(null)
const [userError, setUserError] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
@@ -153,6 +160,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
refreshUsers()
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
@@ -202,6 +210,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
setSelectedBinary(binary)
}
async function handleCreateUser() {
const name = newUserName().trim()
const password = newUserPassword()
if (!name || password.length < 4) {
setUserError("Provide a name and a 4+ character password.")
return
}
setUserError(null)
await createUser(name, password)
setNewUserName("")
setNewUserPassword("")
}
async function handleLogin(userId: string) {
const password = loginTargetId() === userId ? loginPassword() : ""
const ok = await loginUser(userId, password)
if (!ok) {
setUserError("Invalid password.")
return
}
setUserError(null)
setLoginPassword("")
setLoginTargetId(null)
setShowUserModal(false)
}
async function handleGuest() {
await createGuest()
setShowUserModal(false)
}
function handleRemove(path: string, e?: Event) {
if (isLoading()) return
e?.stopPropagation()
@@ -231,6 +270,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<div class="absolute top-4 left-6">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setShowUserModal(true)}
>
Users
</button>
</div>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<button
@@ -244,9 +292,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
<img src={nomadArchLogo} alt="NomadArch logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<h1 class="mb-2 text-3xl font-semibold text-primary">NomadArch</h1>
<p class="text-xs text-muted mb-1">Forked from OpenCode</p>
<Show when={activeUser()}>
{(user) => (
<p class="text-xs text-muted mb-1">
Active user: <span class="text-secondary font-medium">{user().name}</span>
</p>
)}
</Show>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
</div>
@@ -419,6 +475,104 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
<Show when={showUserModal()}>
<div class="modal-overlay">
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="modal-surface w-full max-w-lg p-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-primary">Users</h2>
<button class="selector-button selector-button-secondary" onClick={() => setShowUserModal(false)}>
Close
</button>
</div>
<Show when={userError()}>
{(msg) => <div class="text-sm text-red-400">{msg()}</div>}
</Show>
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-muted">Available</div>
<For each={users()}>
{(user) => (
<div class="flex items-center justify-between gap-3 px-3 py-2 rounded border border-base bg-surface-secondary">
<div class="text-sm text-primary">
{user.name}
<Show when={user.isGuest}>
<span class="ml-2 text-[10px] uppercase text-amber-400">Guest</span>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={!user.isGuest && loginTargetId() === user.id}>
<input
type="password"
placeholder="Password"
value={loginPassword()}
onInput={(event) => setLoginPassword(event.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
</Show>
<button
class="selector-button selector-button-primary"
onClick={() => {
if (user.isGuest) {
void handleLogin(user.id)
return
}
if (loginTargetId() !== user.id) {
setLoginTargetId(user.id)
setLoginPassword("")
return
}
void handleLogin(user.id)
}}
>
{activeUser()?.id === user.id ? "Active" : loginTargetId() === user.id ? "Unlock" : "Login"}
</button>
<button
class="selector-button selector-button-secondary"
onClick={() => void deleteUser(user.id)}
disabled={user.isGuest}
>
Remove
</button>
</div>
</div>
)}
</For>
</div>
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-muted">Create User</div>
<div class="flex flex-col gap-2">
<input
type="text"
placeholder="Name"
value={newUserName()}
onInput={(event) => setNewUserName(event.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
<input
type="password"
placeholder="Password"
value={newUserPassword()}
onInput={(event) => setNewUserPassword(event.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
<div class="flex gap-2">
<button class="selector-button selector-button-primary" onClick={() => void handleCreateUser()}>
Create
</button>
<button class="selector-button selector-button-secondary" onClick={() => void handleGuest()}>
Guest Mode
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
</>
)
}

View File

@@ -1,7 +1,10 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import { serverApi } from "../lib/api-client"
import { showToastNotification } from "../lib/notifications"
interface InstanceInfoProps {
instance: Instance
@@ -22,6 +25,68 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const env = environmentVariables()
return env ? Object.entries(env) : []
})
const [showExportDialog, setShowExportDialog] = createSignal(false)
const [showImportSourceDialog, setShowImportSourceDialog] = createSignal(false)
const [showImportDestinationDialog, setShowImportDestinationDialog] = createSignal(false)
const [importSourcePath, setImportSourcePath] = createSignal<string | null>(null)
const [includeConfig, setIncludeConfig] = createSignal(false)
const [isExporting, setIsExporting] = createSignal(false)
const [isImporting, setIsImporting] = createSignal(false)
const handleExport = async (destination: string) => {
if (isExporting()) return
setIsExporting(true)
try {
const response = await serverApi.exportWorkspace(currentInstance().id, {
destination,
includeConfig: includeConfig(),
})
showToastNotification({
title: "Workspace exported",
message: `Export saved to ${response.destination}`,
variant: "success",
duration: 7000,
})
} catch (error) {
showToastNotification({
title: "Export failed",
message: error instanceof Error ? error.message : "Unable to export workspace",
variant: "error",
duration: 8000,
})
} finally {
setIsExporting(false)
}
}
const handleImportDestination = async (destination: string) => {
const source = importSourcePath()
if (!source || isImporting()) return
setIsImporting(true)
try {
const response = await serverApi.importWorkspace({
source,
destination,
includeConfig: includeConfig(),
})
showToastNotification({
title: "Workspace imported",
message: `Imported workspace into ${response.path}`,
variant: "success",
duration: 7000,
})
} catch (error) {
showToastNotification({
title: "Import failed",
message: error instanceof Error ? error.message : "Unable to import workspace",
variant: "error",
duration: 8000,
})
} finally {
setIsImporting(false)
setImportSourcePath(null)
}
}
return (
<div class="panel">
@@ -116,6 +181,39 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
<div class="space-y-2">
<div class="text-xs font-medium text-muted uppercase tracking-wide">Workspace Export / Import</div>
<label class="flex items-center gap-2 text-xs text-secondary">
<input
type="checkbox"
checked={includeConfig()}
onChange={(event) => setIncludeConfig(event.currentTarget.checked)}
/>
Include user config (settings, keys)
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="button-secondary"
disabled={isExporting()}
onClick={() => setShowExportDialog(true)}
>
{isExporting() ? "Exporting..." : "Export Workspace"}
</button>
<button
type="button"
class="button-secondary"
disabled={isImporting()}
onClick={() => setShowImportSourceDialog(true)}
>
{isImporting() ? "Importing..." : "Import Workspace"}
</button>
</div>
<div class="text-[11px] text-muted">
Export creates a portable folder. Import restores the workspace into a chosen destination.
</div>
</div>
<Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1">
<div class="flex items-center gap-1.5">
@@ -155,6 +253,37 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</div>
</div>
<DirectoryBrowserDialog
open={showExportDialog()}
title="Export workspace to folder"
description="Choose a destination folder for the export package."
onClose={() => setShowExportDialog(false)}
onSelect={(destination) => {
setShowExportDialog(false)
void handleExport(destination)
}}
/>
<DirectoryBrowserDialog
open={showImportSourceDialog()}
title="Select export folder"
description="Pick the export folder that contains the workspace package."
onClose={() => setShowImportSourceDialog(false)}
onSelect={(source) => {
setShowImportSourceDialog(false)
setImportSourcePath(source)
setShowImportDestinationDialog(true)
}}
/>
<DirectoryBrowserDialog
open={showImportDestinationDialog()}
title="Select destination folder"
description="Choose the folder where the workspace should be imported."
onClose={() => setShowImportDestinationDialog(false)}
onSelect={(destination) => {
setShowImportDestinationDialog(false)
void handleImportDestination(destination)
}}
/>
</div>
)
}

View File

@@ -66,6 +66,7 @@ import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
import AdvancedSettingsModal from "../advanced-settings-modal"
import { showConfirmDialog } from "../../stores/alerts"
import {
getSoloState,
toggleAutonomous,
@@ -103,6 +104,7 @@ const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
@@ -150,6 +152,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [terminalOpen, setTerminalOpen] = createSignal(false)
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp", "plan"])
const [currentFile, setCurrentFile] = createSignal<FileNode | null>(null)
const [centerTab, setCenterTab] = createSignal<"code" | "preview">("code")
const [previewUrl, setPreviewUrl] = createSignal<string | null>(null)
const [isSoloOpen, setIsSoloOpen] = createSignal(true)
const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false)
const [selectedBinary, setSelectedBinary] = createSignal("opencode")
@@ -284,6 +288,25 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCleanup(() => window.removeEventListener("open-advanced-settings", handler))
})
onMount(() => {
if (typeof window === "undefined") return
const handler = async (event: Event) => {
const detail = (event as CustomEvent<{ url?: string; instanceId?: string }>).detail
if (!detail || detail.instanceId !== props.instance.id || !detail.url) return
setPreviewUrl(detail.url)
const confirmed = await showConfirmDialog(`Preview available at ${detail.url}. Open now?`, {
title: "Preview ready",
confirmLabel: "Open preview",
cancelLabel: "Later",
})
if (confirmed) {
setCenterTab("preview")
}
}
window.addEventListener(BUILD_PREVIEW_EVENT, handler)
onCleanup(() => window.removeEventListener(BUILD_PREVIEW_EVENT, handler))
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString())
@@ -449,6 +472,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
let sidebarActionId = 0
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null)
const [sidebarRequestedTab, setSidebarRequestedTab] = createSignal<string | null>(null)
const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
target.dispatchEvent(
@@ -499,6 +523,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
setPendingSidebarAction({ action, id: sidebarActionId++ })
if (action === "show-skills") {
setSidebarRequestedTab("skills")
}
if (!leftPinned() && !leftOpen()) {
setLeftOpen(true)
measureDrawerHost()
@@ -902,6 +929,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onToggleTerminal={() => setTerminalOpen((current) => !current)}
isTerminalOpen={terminalOpen()}
onOpenAdvancedSettings={() => setShowAdvancedSettings(true)}
requestedTab={sidebarRequestedTab()}
/>
)
@@ -1243,18 +1271,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button>
</div>
{/* SOLO Mode & Auto-Approval Toggles */}
{/* APEX PRO Mode & Auto-Approval Toggles */}
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-1.5 py-1 space-x-1">
<button
onClick={() => toggleAutonomous(props.instance.id)}
title="Autonomous Mode (SOLO): Enable autonomous AI agent operations"
title="Autonomous Mode (APEX PRO): Enable autonomous AI agent operations"
class={`flex items-center space-x-1.5 px-2 py-0.5 rounded-full transition-all ${getSoloState(props.instance.id).isAutonomous
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
: "text-zinc-500 hover:text-zinc-300"
}`}
>
<Zap size={12} class={getSoloState(props.instance.id).isAutonomous ? "animate-pulse" : ""} />
<span class="text-[9px] font-black uppercase tracking-tighter">SOLO</span>
<span class="text-[9px] font-black uppercase tracking-tighter">APEX PRO</span>
</button>
<button
onClick={() => toggleAutoApproval(props.instance.id)}
@@ -1305,7 +1333,65 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
<div class="flex-1 flex overflow-hidden min-h-0">
<Show when={!isPhoneLayout()}>
<Editor file={currentFile()} />
<div class="flex-1 flex flex-col min-h-0 bg-[#0d0d0d]">
<div class="h-10 glass border-b border-white/5 flex items-center justify-between px-4 shrink-0">
<div class="flex items-center gap-2">
<button
type="button"
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${
centerTab() === "code"
? "bg-white/10 border-white/20 text-white"
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
}`}
onClick={() => setCenterTab("code")}
>
Code
</button>
<button
type="button"
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${
centerTab() === "preview"
? "bg-white/10 border-white/20 text-white"
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
}`}
onClick={() => setCenterTab("preview")}
disabled={!previewUrl()}
title={previewUrl() ? previewUrl() : "Run build to enable preview"}
>
Preview
</button>
</div>
<Show when={previewUrl()}>
{(url) => (
<div class="text-[10px] text-zinc-500 truncate max-w-[50%]" title={url()}>
{url()}
</div>
)}
</Show>
</div>
<Show when={centerTab() === "preview"} fallback={<Editor file={currentFile()} />}>
<Show
when={previewUrl()}
fallback={
<div class="flex-1 flex items-center justify-center text-zinc-500">
<div class="text-center">
<p>No preview available yet.</p>
<p class="text-sm mt-2 opacity-60">Run build to detect a preview URL.</p>
</div>
</div>
}
>
{(url) => (
<iframe
class="flex-1 w-full h-full border-none bg-black"
src={url()}
title="App Preview"
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-popups"
/>
)}
</Show>
</Show>
</div>
</Show>
<div

View File

@@ -1,4 +1,4 @@
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
import { Component, createSignal, For, Show, createEffect, createMemo, onCleanup } from "solid-js"
import {
Files,
Search,
@@ -18,6 +18,7 @@ import {
} from "lucide-solid"
import { serverApi } from "../../lib/api-client"
import InstanceServiceStatus from "../instance-service-status"
import McpManager from "../mcp-manager"
import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills"
import { getSessionSkills, setSessionSkills } from "../../stores/session-state"
@@ -41,6 +42,7 @@ interface SidebarProps {
onToggleTerminal?: () => void
isTerminalOpen?: boolean
onOpenAdvancedSettings?: () => void
requestedTab?: string | null
}
const getFileIcon = (fileName: string) => {
@@ -128,6 +130,7 @@ const FileTree: Component<{
export const Sidebar: Component<SidebarProps> = (props) => {
const [activeTab, setActiveTab] = createSignal("files")
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
const [lastRequestedTab, setLastRequestedTab] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("")
const [searchResults, setSearchResults] = createSignal<FileNode[]>([])
const [searchLoading, setSearchLoading] = createSignal(false)
@@ -141,9 +144,15 @@ export const Sidebar: Component<SidebarProps> = (props) => {
} | null>(null)
const [gitLoading, setGitLoading] = createSignal(false)
const [skillsFilter, setSkillsFilter] = createSignal("")
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
createEffect(async () => {
if (props.instanceId) {
const openExternal = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
const refreshRootFiles = async () => {
if (!props.instanceId) return
try {
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
setRootFiles(entries.map(e => ({
@@ -155,6 +164,20 @@ export const Sidebar: Component<SidebarProps> = (props) => {
console.error("Failed to load root files", e)
}
}
createEffect(() => {
void refreshRootFiles()
})
createEffect(() => {
if (typeof window === "undefined") return
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ instanceId?: string }>).detail
if (!detail || detail.instanceId !== props.instanceId) return
void refreshRootFiles()
}
window.addEventListener(FILE_CHANGE_EVENT, handler)
onCleanup(() => window.removeEventListener(FILE_CHANGE_EVENT, handler))
})
createEffect(() => {
@@ -163,6 +186,13 @@ export const Sidebar: Component<SidebarProps> = (props) => {
}
})
createEffect(() => {
const nextTab = props.requestedTab ?? null
if (!nextTab || nextTab === lastRequestedTab()) return
setActiveTab(nextTab)
setLastRequestedTab(nextTab)
})
const filteredSkills = createMemo(() => {
const term = skillsFilter().trim().toLowerCase()
if (!term) return catalog()
@@ -410,10 +440,7 @@ export const Sidebar: Component<SidebarProps> = (props) => {
</div>
</Show>
<Show when={activeTab() === "mcp"}>
<div class="flex flex-col gap-3">
<div class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</div>
<InstanceServiceStatus sections={["mcp"]} />
</div>
<McpManager instanceId={props.instanceId} />
</Show>
<Show when={activeTab() === "skills"}>
<div class="flex flex-col gap-3">

View File

@@ -0,0 +1,501 @@
import { Dialog } from "@kobalte/core/dialog"
import { ChevronDown, ExternalLink, Plus, RefreshCw, Search, Settings } from "lucide-solid"
import { Component, For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
import InstanceServiceStatus from "./instance-service-status"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
type McpServerConfig = {
command?: string
args?: string[]
env?: Record<string, string>
}
type McpConfig = {
mcpServers?: Record<string, McpServerConfig>
}
type McpMarketplaceEntry = {
id: string
name: string
description: string
config: McpServerConfig
tags?: string[]
source?: string
}
interface McpManagerProps {
instanceId: string
}
const log = getLogger("mcp-manager")
const MCP_LINKER_RELEASES = "https://github.com/milisp/mcp-linker/releases"
const MCP_LINKER_MARKET = "https://github.com/milisp/mcp-linker"
const MARKETPLACE_ENTRIES: McpMarketplaceEntry[] = [
{
id: "sequential-thinking",
name: "Sequential Thinking",
description: "Step-by-step reasoning scratchpad for complex tasks.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-sequential-thinking"] },
tags: ["reasoning", "planning"],
source: "curated",
},
{
id: "desktop-commander",
name: "Desktop Commander",
description: "Control local desktop actions and automation.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-desktop-commander"] },
tags: ["automation", "local"],
source: "curated",
},
{
id: "web-reader",
name: "Web Reader",
description: "Fetch and summarize web pages with structured metadata.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-web-reader"] },
tags: ["web", "search"],
source: "curated",
},
{
id: "github",
name: "GitHub",
description: "Query GitHub repos, issues, and pull requests.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
tags: ["git", "productivity"],
source: "curated",
},
{
id: "postgres",
name: "PostgreSQL",
description: "Inspect PostgreSQL schemas and run safe queries.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-postgres"] },
tags: ["database"],
source: "curated",
},
]
const McpManager: Component<McpManagerProps> = (props) => {
const [config, setConfig] = createSignal<McpConfig>({ mcpServers: {} })
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [menuOpen, setMenuOpen] = createSignal(false)
const [showManual, setShowManual] = createSignal(false)
const [showMarketplace, setShowMarketplace] = createSignal(false)
const [marketplaceQuery, setMarketplaceQuery] = createSignal("")
const [marketplaceLoading, setMarketplaceLoading] = createSignal(false)
const [marketplaceEntries, setMarketplaceEntries] = createSignal<McpMarketplaceEntry[]>([])
const [rawMode, setRawMode] = createSignal(false)
const [serverName, setServerName] = createSignal("")
const [serverJson, setServerJson] = createSignal("")
const [saving, setSaving] = createSignal(false)
const metadataContext = useOptionalInstanceMetadataContext()
const metadata = createMemo(() => metadataContext?.metadata?.() ?? null)
const mcpStatus = createMemo(() => metadata()?.mcpStatus ?? {})
const servers = createMemo(() => Object.entries(config().mcpServers ?? {}))
const filteredMarketplace = createMemo(() => {
const combined = [...MARKETPLACE_ENTRIES, ...marketplaceEntries()]
const query = marketplaceQuery().trim().toLowerCase()
if (!query) return combined
return combined.filter((entry) => {
const haystack = `${entry.name} ${entry.description} ${entry.id} ${(entry.tags || []).join(" ")}`.toLowerCase()
return haystack.includes(query)
})
})
const loadConfig = async () => {
setIsLoading(true)
setError(null)
try {
const data = await serverApi.fetchWorkspaceMcpConfig(props.instanceId)
setConfig(data.config ?? { mcpServers: {} })
} catch (err) {
log.error("Failed to load MCP config", err)
setError("Failed to load MCP configuration.")
} finally {
setIsLoading(false)
}
}
createEffect(() => {
void loadConfig()
})
const openExternal = (url: string) => {
window.open(url, "_blank", "noopener")
}
const resetManualForm = () => {
setServerName("")
setServerJson("")
setRawMode(false)
}
const handleManualSave = async () => {
if (saving()) return
setSaving(true)
setError(null)
try {
const parsed = JSON.parse(serverJson() || "{}")
const nextConfig: McpConfig = { ...(config() ?? {}) }
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
if (rawMode()) {
if (!parsed || typeof parsed !== "object") {
throw new Error("Raw config must be a JSON object.")
}
setConfig(parsed as McpConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, parsed)
} else {
const name = serverName().trim()
if (!name) {
throw new Error("Server name is required.")
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Server config must be a JSON object.")
}
mcpServers[name] = parsed as McpServerConfig
nextConfig.mcpServers = mcpServers
setConfig(nextConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
}
resetManualForm()
setShowManual(false)
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid MCP configuration."
setError(message)
} finally {
setSaving(false)
}
}
const handleMarketplaceInstall = async (entry: McpMarketplaceEntry) => {
if (saving()) return
setSaving(true)
setError(null)
try {
const nextConfig: McpConfig = { ...(config() ?? {}) }
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
mcpServers[entry.id] = entry.config
nextConfig.mcpServers = mcpServers
setConfig(nextConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to install MCP server."
setError(message)
} finally {
setSaving(false)
}
}
const fetchNpmEntries = async (query: string, sourceLabel: string): Promise<McpMarketplaceEntry[]> => {
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=50`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${sourceLabel} MCP entries`)
}
const data = await response.json() as {
objects?: Array<{ package?: { name?: string; description?: string; keywords?: string[] } }>
}
const objects = Array.isArray(data.objects) ? data.objects : []
return objects
.map((entry) => entry.package)
.filter((pkg): pkg is { name: string; description?: string; keywords?: string[] } => Boolean(pkg?.name))
.map((pkg) => ({
id: pkg.name,
name: pkg.name.replace(/^@modelcontextprotocol\/server-/, ""),
description: pkg.description || "Community MCP server package",
config: { command: "npx", args: ["-y", pkg.name] },
tags: pkg.keywords,
source: sourceLabel,
}))
}
const loadMarketplace = async () => {
if (marketplaceLoading()) return
setMarketplaceLoading(true)
try {
const [official, community] = await Promise.allSettled([
fetchNpmEntries("@modelcontextprotocol/server", "npm:official"),
fetchNpmEntries("mcp server", "npm:community"),
])
const next: McpMarketplaceEntry[] = []
if (official.status === "fulfilled") next.push(...official.value)
if (community.status === "fulfilled") next.push(...community.value)
const deduped = new Map<string, McpMarketplaceEntry>()
for (const entry of next) {
if (!deduped.has(entry.id)) deduped.set(entry.id, entry)
}
setMarketplaceEntries(Array.from(deduped.values()))
} catch (err) {
log.error("Failed to load marketplace", err)
setError("Failed to load marketplace sources.")
} finally {
setMarketplaceLoading(false)
}
}
return (
<div class="mcp-manager">
<div class="mcp-manager-header">
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</span>
<button
onClick={loadConfig}
class="mcp-icon-button"
title="Refresh MCP servers"
>
<RefreshCw size={12} />
</button>
</div>
<div class="mcp-manager-actions">
<div class="relative">
<button
onClick={() => setMenuOpen((prev) => !prev)}
class="mcp-action-button"
title="Add MCP"
>
<Plus size={12} />
<span>Add</span>
<ChevronDown size={12} />
</button>
<Show when={menuOpen()}>
<div class="mcp-menu">
<button
class="mcp-menu-item"
onClick={() => {
setMenuOpen(false)
void loadMarketplace()
setShowMarketplace(true)
}}
>
Add from Marketplace
<ExternalLink size={12} />
</button>
<button
class="mcp-menu-item"
onClick={() => {
setMenuOpen(false)
resetManualForm()
setShowManual(true)
}}
>
Add Manually
</button>
</div>
</Show>
</div>
<button
onClick={() => openExternal(MCP_LINKER_RELEASES)}
class="mcp-link-button"
title="Install MCP Linker"
>
MCP Market
</button>
</div>
</div>
<Show when={error()}>
{(err) => <div class="text-[11px] text-amber-400">{err()}</div>}
</Show>
<Show
when={!isLoading() && servers().length > 0}
fallback={<div class="text-[11px] text-zinc-500 italic">{isLoading() ? "Loading MCP servers..." : "No MCP servers configured."}</div>}
>
<div class="mcp-server-list">
<For each={servers()}>
{([name, server]) => (
<div class="mcp-server-card">
<div class="mcp-server-row">
<div class="flex flex-col">
<span class="text-xs font-semibold text-zinc-100">{name}</span>
<span class="text-[11px] text-zinc-500 truncate">
{server.command ? `${server.command} ${(server.args ?? []).join(" ")}` : "Custom config"}
</span>
</div>
<div class="flex items-center gap-2">
<Show when={mcpStatus()?.[name]?.status}>
<span class="mcp-status-chip">
{mcpStatus()?.[name]?.status}
</span>
</Show>
<Show when={mcpStatus()?.[name]?.error}>
<span class="mcp-status-error" title={String(mcpStatus()?.[name]?.error)}>
error
</span>
</Show>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
<div class="mt-3">
<InstanceServiceStatus sections={["mcp"]} />
</div>
<Dialog open={showManual()} onOpenChange={setShowManual} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-2xl p-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<Dialog.Title class="text-sm font-semibold text-white">Configure MCP Server</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500">
Paste the MCP server config JSON. Use marketplace via MCP Linker for curated servers.
</Dialog.Description>
</div>
<button
class="text-xs px-2 py-1 rounded border border-white/10 text-zinc-400 hover:text-white"
onClick={() => setRawMode((prev) => !prev)}
>
{rawMode() ? "Server Mode" : "Raw Config (JSON)"}
</button>
</div>
<Show when={!rawMode()}>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Server Name
<input
value={serverName()}
onInput={(e) => setServerName(e.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
placeholder="example-server"
/>
</label>
</Show>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Config JSON
<textarea
value={serverJson()}
onInput={(e) => setServerJson(e.currentTarget.value)}
class="min-h-[200px] rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500/60"
placeholder='{"command":"npx","args":["-y","mcp-server-example"]}'
/>
</label>
<div class="flex items-center justify-end gap-2">
<button
onClick={() => {
resetManualForm()
setShowManual(false)
}}
class="px-3 py-1.5 text-xs rounded-md border border-white/10 text-zinc-300 hover:text-white"
>
Cancel
</button>
<button
onClick={handleManualSave}
disabled={saving()}
class="px-3 py-1.5 text-xs rounded-md bg-blue-500/20 border border-blue-500/40 text-blue-200 hover:text-white disabled:opacity-60"
>
{saving() ? "Saving..." : "Confirm"}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<Dialog open={showMarketplace()} onOpenChange={setShowMarketplace} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-3xl p-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<Dialog.Title class="text-sm font-semibold text-white">MCP Marketplace</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500">
Curated entries inspired by mcp-linker. Install writes to this workspace&apos;s .mcp.json.
</Dialog.Description>
</div>
<button
class="mcp-link-button"
onClick={() => openExternal(MCP_LINKER_MARKET)}
>
Open MCP Linker
</button>
</div>
<div class="mcp-market-search">
<Search size={14} class="text-zinc-500" />
<input
value={marketplaceQuery()}
onInput={(e) => setMarketplaceQuery(e.currentTarget.value)}
placeholder="Search MCP servers..."
class="mcp-market-input"
/>
</div>
<div class="mcp-market-list">
<Show
when={!marketplaceLoading()}
fallback={<div class="text-[11px] text-zinc-500 italic">Loading marketplace sources...</div>}
>
<For each={filteredMarketplace()}>
{(entry) => (
<div class="mcp-market-card">
<div class="mcp-market-card-info">
<div class="mcp-market-card-title">
{entry.name}
<Show when={entry.source}>
{(source) => <span class="mcp-market-source">{source()}</span>}
</Show>
</div>
<div class="mcp-market-card-desc">{entry.description}</div>
<Show when={entry.tags && entry.tags.length > 0}>
<div class="mcp-market-tags">
<For each={entry.tags}>
{(tag) => <span class="mcp-market-tag">{tag}</span>}
</For>
</div>
</Show>
</div>
<div class="mcp-market-card-actions">
<button
class="mcp-icon-button"
title="View config"
onClick={() => {
setShowManual(true)
setRawMode(false)
setServerName(entry.id)
setServerJson(JSON.stringify(entry.config, null, 2))
setShowMarketplace(false)
}}
>
<Settings size={14} />
</button>
<button
class="mcp-market-install"
onClick={() => handleMarketplaceInstall(entry)}
disabled={saving()}
>
<Plus size={12} />
Install
</button>
</div>
</div>
)}
</For>
</Show>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</div>
)
}
export default McpManager

View File

@@ -2,6 +2,8 @@ import { For, Show, createSignal } from "solid-js"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { compactSession } from "../stores/session-actions"
import { clearCompactionSuggestion } from "../stores/session-compaction"
import MessagePart from "./message-part"
interface MessageItemProps {
@@ -125,6 +127,27 @@ interface MessageItemProps {
return null
}
const isContextError = () => {
const info = props.messageInfo
if (!info) return false
const errorMessage = (info as any).error?.data?.message || (info as any).error?.message || ""
return (
errorMessage.includes("maximum context length") ||
errorMessage.includes("context_length_exceeded") ||
errorMessage.includes("token count exceeds") ||
errorMessage.includes("token limit")
)
}
const handleCompact = async () => {
try {
clearCompactionSuggestion(props.instanceId, props.sessionId)
await compactSession(props.instanceId, props.sessionId)
} catch (error) {
console.error("Failed to compact session:", error)
}
}
const hasContent = () => {
if (errorMessage() !== null) {
return true
@@ -138,6 +161,19 @@ interface MessageItemProps {
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
}
const isStreaming = () => {
return props.record.status === "streaming"
}
const currentTokenCount = () => {
if (!isStreaming()) return null
const textParts = props.parts.filter(p => p.type === "text")
return textParts.reduce((sum, p) => {
const text = (p as { text?: string }).text || ""
return sum + text.length
}, 0)
}
const handleRevert = () => {
if (props.onRevert && isUser()) {
props.onRevert(props.record.id)
@@ -185,7 +221,7 @@ interface MessageItemProps {
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
return modelID || "unknown"
}
const agentMeta = () => {
@@ -202,6 +238,20 @@ interface MessageItemProps {
return segments.join(" • ")
}
const modelBadge = () => {
if (isUser()) return null
const model = modelIdentifier()
if (!model) return null
return (
<span class="message-model-badge" title={`Model: ${model}`}>
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="text-xs font-medium text-zinc-400">{model}</span>
</span>
)
}
return (
<div class={containerClass()}>
@@ -259,6 +309,11 @@ interface MessageItemProps {
</Show>
</button>
</Show>
<Show when={modelBadge()}>
{(badge) => (
<span class="ml-2">{badge()}</span>
)}
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
@@ -266,13 +321,45 @@ interface MessageItemProps {
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={isStreaming()}>
<div class="message-streaming-indicator">
<span class="streaming-status">
<span class="streaming-pulse"></span>
<span class="streaming-text">Thinking</span>
</span>
<Show when={currentTokenCount() !== null}>
{(count) => (
<span class="streaming-tokens">
<span class="streaming-token-count">{count()}</span>
<span class="streaming-token-label">tokens</span>
</span>
)}
</Show>
</div>
</Show>
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div>
</Show>
<Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div>
<div class="message-error-block">
<div class="flex items-start gap-2">
<span> {errorMessage()}</span>
<Show when={isContextError()}>
<button
onClick={handleCompact}
class="compact-button"
title="Compact session to reduce context usage"
>
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16l6-6-6 6M4 20l6-6 6-6" />
</svg>
Compact
</button>
</Show>
</div>
</div>
</Show>
<Show when={isGenerating()}>

View File

@@ -7,6 +7,9 @@ import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import { getSessionStatus } from "../stores/session-status"
import { compactSession } from "../stores/session-actions"
import { clearCompactionSuggestion, getCompactionSuggestion } from "../stores/session-compaction"
const SCROLL_SCOPE = "session"
const SCROLL_SENTINEL_MARGIN_PX = 48
@@ -51,6 +54,10 @@ export default function MessageSection(props: MessageSectionProps) {
contextAvailableTokens: null,
},
)
const isCompacting = createMemo(() => getSessionStatus(props.instanceId, props.sessionId) === "compacting")
const compactionSuggestion = createMemo(() =>
getCompactionSuggestion(props.instanceId, props.sessionId),
)
const tokenStats = createMemo(() => {
const usage = usageSnapshot()
@@ -747,6 +754,30 @@ export default function MessageSection(props: MessageSectionProps) {
<div class="message-stream-container">
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
<div class="message-stream-shell" ref={setShellElement}>
<Show when={isCompacting()}>
<div class="compaction-banner" role="status" aria-live="polite">
<span class="spinner compaction-banner-spinner" aria-hidden="true" />
<span>Compacting context</span>
</div>
</Show>
<Show when={!isCompacting() && compactionSuggestion()}>
<div class="compaction-suggestion" role="status" aria-live="polite">
<div class="compaction-suggestion-text">
<span class="compaction-suggestion-label">Compact suggested</span>
<span class="compaction-suggestion-message">{compactionSuggestion()!.reason}</span>
</div>
<button
type="button"
class="compaction-suggestion-action"
onClick={() => {
clearCompactionSuggestion(props.instanceId, props.sessionId)
void compactSession(props.instanceId, props.sessionId)
}}
>
Compact now
</button>
</div>
</Show>
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
<Show when={!props.loading && messageIds().length === 0}>

View File

@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session"
import { getLogger } from "../lib/logger"
import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("session")
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
@@ -40,7 +41,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
const readOfflineModels = () => {
if (typeof window === "undefined") return new Set<string>()
try {
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const raw = window.localStorage.getItem(getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY))
const parsed = raw ? JSON.parse(raw) : []
return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : [])
} catch {
@@ -57,7 +58,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
if (typeof window === "undefined") return
const handleCustom = () => refreshOfflineModels()
const handleStorage = (event: StorageEvent) => {
if (event.key === OPENCODE_ZEN_OFFLINE_STORAGE_KEY) {
if (event.key === getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)) {
refreshOfflineModels()
}
}

View File

@@ -1169,6 +1169,12 @@ export default function PromptInput(props: PromptInputProps) {
</div>
<div class="prompt-input-actions">
<Show when={props.isSessionBusy}>
<div class="thinking-indicator" aria-live="polite">
<span class="thinking-spinner" aria-hidden="true" />
<span>Thinking</span>
</div>
</Show>
<button
type="button"
class="stop-button"

View File

@@ -2,6 +2,8 @@ import { Component, createSignal, onMount, Show } from 'solid-js'
import toast from 'solid-toast'
import { Button } from '@suid/material'
import { Cloud, CheckCircle, XCircle, Loader } from 'lucide-solid'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
interface OllamaCloudConfig {
enabled: boolean
@@ -12,9 +14,11 @@ interface OllamaCloudConfig {
interface OllamaCloudModelsResponse {
models: Array<{
name: string
size: string
digest: string
modified_at: string
model?: string
size?: string | number
digest?: string
modified_at?: string
details?: any
}>
}
@@ -25,14 +29,20 @@ const OllamaCloudSettings: Component = () => {
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
const [models, setModels] = createSignal<string[]>([])
const [isLoadingModels, setIsLoadingModels] = createSignal(false)
const [hasStoredApiKey, setHasStoredApiKey] = createSignal(false)
// Load config on mount
onMount(async () => {
try {
const response = await fetch('http://localhost:6149/api/ollama/config')
const response = await fetch('/api/ollama/config')
if (response.ok) {
const data = await response.json()
setConfig(data.config)
const maskedKey = typeof data.config?.apiKey === "string" && /^\*+$/.test(data.config.apiKey)
setHasStoredApiKey(Boolean(data.config?.apiKey) && maskedKey)
setConfig({
...data.config,
apiKey: maskedKey ? "" : data.config?.apiKey,
})
}
} catch (error) {
console.error('Failed to load Ollama config:', error)
@@ -47,10 +57,15 @@ const OllamaCloudSettings: Component = () => {
const saveConfig = async () => {
setIsLoading(true)
try {
const response = await fetch('http://localhost:6149/api/ollama/config', {
const payload: OllamaCloudConfig = { ...config() }
if (!payload.apiKey && hasStoredApiKey()) {
delete payload.apiKey
}
const response = await fetch('/api/ollama/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config())
body: JSON.stringify(payload)
})
if (response.ok) {
@@ -58,6 +73,16 @@ const OllamaCloudSettings: Component = () => {
duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" />
})
// Refresh providers for all instances so models appear in selector
const instanceList = Array.from(instances().values())
for (const instance of instanceList) {
try {
await fetchProviders(instance.id)
} catch (error) {
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
}
}
} else {
throw new Error('Failed to save config')
}
@@ -76,7 +101,7 @@ const OllamaCloudSettings: Component = () => {
setConnectionStatus('testing')
try {
const response = await fetch('http://localhost:6149/api/ollama/test', {
const response = await fetch('/api/ollama/test', {
method: 'POST'
})
@@ -115,13 +140,32 @@ const OllamaCloudSettings: Component = () => {
const loadModels = async () => {
setIsLoadingModels(true)
try {
const response = await fetch('http://localhost:6149/api/ollama/models/cloud')
const response = await fetch('/api/ollama/models')
if (response.ok) {
const data: OllamaCloudModelsResponse = await response.json()
setModels(data.models.map(model => model.name))
const data = await response.json()
// Handle different response formats
if (data.models && Array.isArray(data.models)) {
setModels(data.models.map((model: any) => model.name || model.model || 'unknown'))
if (data.models.length > 0) {
toast.success(`Loaded ${data.models.length} models`, { duration: 2000 })
}
} else {
console.warn('Unexpected models response format:', data)
setModels([])
}
} else {
const errorData = await response.json().catch(() => ({}))
toast.error(`Failed to load models: ${errorData.error || response.statusText}`, {
duration: 5000,
icon: <XCircle class="w-4 h-4 text-red-500" />
})
}
} catch (error) {
console.error('Failed to load models:', error)
toast.error('Failed to load models - network error', {
duration: 5000,
icon: <XCircle class="w-4 h-4 text-red-500" />
})
} finally {
setIsLoadingModels(false)
}
@@ -164,12 +208,13 @@ const OllamaCloudSettings: Component = () => {
<label class="block font-medium mb-2">API Key</label>
<input
type="password"
placeholder="Enter your Ollama Cloud API key"
placeholder={hasStoredApiKey() ? "API key stored (leave empty to keep)" : "Enter your Ollama Cloud API key"}
value={config().apiKey || ''}
onChange={(e) => handleConfigChange('apiKey', e.target.value)}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={!config().enabled}
/>
<p class="text-xs text-gray-500 mt-1">Get your API key from <a href="https://ollama.com/settings/keys" target="_blank" class="text-blue-500 underline">ollama.com/settings/keys</a></p>
</div>
{/* Endpoint */}
@@ -183,6 +228,7 @@ const OllamaCloudSettings: Component = () => {
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={!config().enabled}
/>
<p class="text-xs text-gray-500 mt-1">Default: https://ollama.com (for local Ollama use: http://localhost:11434)</p>
</div>
{/* Test Connection */}
@@ -208,8 +254,8 @@ const OllamaCloudSettings: Component = () => {
{/* Available Models */}
<Show when={models().length > 0}>
<div>
<label class="block font-medium mb-2">Available Cloud Models</label>
<div class="grid grid-cols-1 gap-2">
<label class="block font-medium mb-2">Available Models</label>
<div class="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
{models().map(model => (
<div class="p-3 border border-gray-200 rounded-md bg-gray-50">
<code class="text-sm font-mono">{model}</code>

View File

@@ -3,6 +3,8 @@ import toast from 'solid-toast'
import { Button } from '@suid/material'
import { User, CheckCircle, XCircle, Loader, LogOut, ExternalLink } from 'lucide-solid'
import { useQwenOAuth } from '../../lib/integrations/qwen-oauth'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
interface QwenUser {
id: string
@@ -17,7 +19,7 @@ interface QwenUser {
}
const QwenCodeSettings: Component = () => {
const { isAuthenticated, user, isLoading, signIn, signOut, createApiClient } = useQwenOAuth()
const { isAuthenticated, user, isLoading, signIn, signOut, tokenInfo } = useQwenOAuth()
const [isSigningOut, setIsSigningOut] = createSignal(false)
const handleSignIn = async () => {
@@ -27,6 +29,13 @@ const QwenCodeSettings: Component = () => {
duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" />
})
for (const instance of instances().values()) {
try {
await fetchProviders(instance.id)
} catch (error) {
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
}
}
} catch (error) {
toast.error('Failed to authenticate with Qwen Code', {
duration: 5000,
@@ -59,6 +68,32 @@ const QwenCodeSettings: Component = () => {
return `${user.limits.requests_per_day} requests/day, ${user.limits.requests_per_minute}/min`
}
const formatTokenExpiry = () => {
const token = tokenInfo()
if (!token) return "Token not available"
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000
const remainingMs = Math.max(0, expiresAt - Date.now())
const remainingMin = Math.floor(remainingMs / 60000)
return `${remainingMin} min remaining`
}
const tokenStatus = () => {
const token = tokenInfo()
if (!token) return "Unknown"
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000
return Date.now() < expiresAt ? "Active" : "Expired"
}
const tokenId = () => {
const token = tokenInfo()
if (!token?.access_token) return "Unavailable"
const value = token.access_token
if (value.length <= 12) return value
return `${value.slice(0, 6)}...${value.slice(-4)}`
}
return (
<div class="space-y-6 p-6">
<div class="flex items-center gap-2 mb-4">
@@ -128,6 +163,16 @@ const QwenCodeSettings: Component = () => {
{formatRemainingRequests(user()!)}
</span>
</Show>
<span class="text-xs text-green-600 dark:text-green-400">
{formatTokenExpiry()}
</span>
</div>
<div class="flex items-center gap-2 mt-2 text-xs text-green-700 dark:text-green-300">
<span class="font-semibold">Token ID:</span>
<span class="font-mono">{tokenId()}</span>
<span class="px-2 py-0.5 rounded-full bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200">
{tokenStatus()}
</span>
</div>
</div>
</div>

View File

@@ -137,7 +137,7 @@ const ZAISettings: Component = () => {
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">GLM Coding Plan</h3>
<p class="text-sm text-blue-800 dark:text-blue-200">
Z.AI provides access to Claude models through their GLM Coding Plan. Get your API key from the{' '}
Z.AI provides access to GLM-4.7, GLM-4.6, GLM-4.5, and other GLM models through their PaaS/v4 API. Get your API key from the{' '}
<a
href="https://z.ai/manage-apikey/apikey-list"
target="_blank"
@@ -182,12 +182,11 @@ const ZAISettings: Component = () => {
</p>
</div>
{/* Endpoint */}
<div>
<label class="block font-medium mb-2">Endpoint</label>
<input
type="text"
placeholder="https://api.z.ai/api/anthropic"
placeholder="https://api.z.ai/api/paas/v4"
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

@@ -29,6 +29,7 @@ const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
function makeRenderCacheKey(
toolCallId?: string | null,
@@ -304,6 +305,7 @@ export default function ToolCall(props: ToolCallProps) {
let toolCallRootRef: HTMLDivElement | undefined
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let lastFileEventKey = ""
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
@@ -493,6 +495,19 @@ export default function ToolCall(props: ToolCallProps) {
})
})
createEffect(() => {
const state = toolState()
if (!state || state.status !== "completed") return
const tool = toolName()
if (!["write", "edit", "patch"].includes(tool)) return
const key = `${toolCallIdentifier()}:${tool}:${state.status}`
if (key === lastFileEventKey) return
lastFileEventKey = key
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId: props.instanceId } }))
}
})
createEffect(() => {
const activeKey = activePermissionKey()
if (!activeKey) return

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

View File

@@ -6,6 +6,7 @@
@import './styles/markdown.css';
@import './styles/tabs.css';
@import './styles/antigravity.css';
@import './styles/responsive.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -18,6 +19,7 @@
html,
body {
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
@@ -27,12 +29,18 @@ body {
-moz-osx-font-smoothing: grayscale;
background-color: var(--surface-base);
color: var(--text-primary);
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
#root {
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
background-color: var(--surface-base);
overflow: hidden;
}
@@ -61,6 +69,5 @@ body {

View File

@@ -0,0 +1,390 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
validateStructuredSummary,
validateCompactionEvent,
validateCompactionResult,
sanitizeStructuredSummary,
type StructuredSummary,
type CompactionEvent,
type CompactionResult,
} from "../compaction-schema.js"
describe("compaction schema", () => {
describe("validateStructuredSummary", () => {
it("validates tierA summary", () => {
const summary: StructuredSummary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Created API endpoint", "Added error handling"],
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
current_state: "API endpoint implemented with error handling",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(result.success)
assert.equal(result.data.summary_type, "tierA_short")
})
it("validates tierB summary", () => {
const summary: StructuredSummary = {
timestamp: new Date().toISOString(),
summary_type: "tierB_detailed",
what_was_done: ["Created API endpoint", "Added error handling", "Wrote unit tests"],
files: [
{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" },
{ path: "src/api.test.ts", notes: "Test file", decision_id: "decision-2" },
],
current_state: "API endpoint implemented with error handling and full test coverage",
key_decisions: [
{
id: "decision-1",
decision: "Use Fastify for performance",
rationale: "Fastify provides better performance than Express",
actor: "agent",
},
],
next_steps: ["Add authentication", "Implement rate limiting"],
blockers: [],
artifacts: [],
tags: ["api", "fastify"],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1500,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(result.success)
assert.equal(result.data.summary_type, "tierB_detailed")
assert.ok(result.data.key_decisions)
assert.equal(result.data.key_decisions.length, 1)
})
it("rejects invalid timestamp", () => {
const summary = {
timestamp: "invalid-date",
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
assert.ok(result.errors.length > 0)
})
it("rejects empty what_was_done array", () => {
const summary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: [],
files: [],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
assert.ok(result.errors.some((e) => e.includes("what_was_done")))
})
it("rejects empty current_state", () => {
const summary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
assert.ok(result.errors.some((e) => e.includes("current_state")))
})
it("rejects invalid actor in key_decisions", () => {
const summary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "API endpoint implemented",
key_decisions: [
{
id: "decision-1",
decision: "Use Fastify",
rationale: "Performance",
actor: "invalid" as any,
},
],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
})
})
describe("validateCompactionEvent", () => {
it("validates user-triggered compaction", () => {
const event: CompactionEvent = {
event_id: "comp_1234567890",
timestamp: new Date().toISOString(),
actor: "user",
trigger_reason: "manual",
token_before: 10000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(result.success)
assert.equal(result.data.actor, "user")
})
it("validates auto-triggered compaction", () => {
const event: CompactionEvent = {
event_id: "auto_1234567890",
timestamp: new Date().toISOString(),
actor: "auto",
trigger_reason: "overflow",
token_before: 15000,
token_after: 5000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.07,
}
const result = validateCompactionEvent(event)
assert.ok(result.success)
assert.equal(result.data.actor, "auto")
assert.equal(result.data.trigger_reason, "overflow")
})
it("rejects negative token values", () => {
const event = {
event_id: "comp_1234567890",
timestamp: new Date().toISOString(),
actor: "user" as const,
trigger_reason: "manual" as const,
token_before: -1000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(!result.success)
})
it("rejects empty event_id", () => {
const event = {
event_id: "",
timestamp: new Date().toISOString(),
actor: "user" as const,
trigger_reason: "manual" as const,
token_before: 10000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(!result.success)
})
it("rejects invalid actor", () => {
const event = {
event_id: "comp_1234567890",
timestamp: new Date().toISOString(),
actor: "invalid" as any,
trigger_reason: "manual" as const,
token_before: 10000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(!result.success)
})
})
describe("validateCompactionResult", () => {
it("validates successful compaction", () => {
const result: CompactionResult = {
success: true,
mode: "compact",
human_summary: "Compacted 100 messages",
detailed_summary: {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Compacted 100 messages"],
files: [],
current_state: "Session compacted",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
},
token_before: 10000,
token_after: 3000,
token_reduction_pct: 70,
}
const validation = validateCompactionResult(result)
assert.ok(validation.success)
})
it("validates failed compaction", () => {
const result: CompactionResult = {
success: false,
mode: "compact",
human_summary: "Compaction failed",
token_before: 10000,
token_after: 10000,
token_reduction_pct: 0,
}
const validation = validateCompactionResult(result)
assert.ok(validation.success)
assert.equal(validation.data.success, false)
})
it("rejects invalid token reduction percentage", () => {
const result = {
success: true,
mode: "compact" as const,
human_summary: "Compacted 100 messages",
token_before: 10000,
token_after: 3000,
token_reduction_pct: 150,
}
const validation = validateCompactionResult(result)
assert.ok(!validation.success)
})
it("rejects negative token reduction percentage", () => {
const result = {
success: true,
mode: "compact" as const,
human_summary: "Compacted 100 messages",
token_before: 10000,
token_after: 3000,
token_reduction_pct: -10,
}
const validation = validateCompactionResult(result)
assert.ok(!validation.success)
})
})
describe("sanitizeStructuredSummary", () => {
it("sanitizes summary by removing extra fields", () => {
const dirtySummary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
extraField: "should be removed",
anotherExtra: { nested: "data" },
}
const clean = sanitizeStructuredSummary(dirtySummary)
assert.ok(clean)
assert.ok(!("extraField" in clean))
assert.ok(!("anotherExtra" in clean))
assert.equal(clean?.summary_type, "tierA_short")
})
it("preserves all valid fields", () => {
const summary: StructuredSummary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Created API endpoint"],
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: ["Add tests"],
blockers: [],
artifacts: [],
tags: ["api"],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const clean = sanitizeStructuredSummary(summary)
assert.ok(clean)
assert.equal(clean?.what_was_done.length, 1)
assert.ok(clean?.files)
assert.equal(clean.files.length, 1)
assert.ok(clean?.next_steps)
assert.equal(clean.next_steps.length, 1)
assert.ok(clean?.tags)
assert.equal(clean.tags.length, 1)
})
})
})

View File

@@ -0,0 +1,158 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { redactSecrets, hasSecrets, redactObject } from "../secrets-detector.js"
describe("secrets detector", () => {
describe("redactSecrets", () => {
it("redacts API keys", () => {
const content = "My API key is sk-1234567890abcdef"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("sk-1234567890abcdef"))
})
it("redacts AWS access keys", () => {
const content = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("AKIAIOSFODNN7EXAMPLE"))
})
it("redacts bearer tokens", () => {
const content = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
})
it("redacts GitHub tokens", () => {
const content = "github_pat_11AAAAAAAAAAAAAAAAAAAAAA"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("github_pat_11AAAAAAAAAAAAAAAAAAAAAA"))
})
it("redacts npm tokens", () => {
const content = "npm_1234567890abcdef1234567890abcdef1234"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("npm_1234567890abcdef1234567890abcdef1234"))
})
it("preserves non-sensitive content", () => {
const content = "This is a normal message without any secrets"
const result = redactSecrets(content, "test")
assert.equal(result.clean, content)
assert.equal(result.redactions.length, 0)
})
it("handles empty content", () => {
const content = ""
const result = redactSecrets(content, "test")
assert.equal(result.clean, "")
assert.equal(result.redactions.length, 0)
})
it("provides redaction reasons", () => {
const content = "API key: sk-1234567890abcdef"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(result.redactions[0].reason.length > 0)
})
it("tracks redaction paths", () => {
const content = "sk-1234567890abcdef"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.equal(typeof result.redactions[0].path, "string")
assert.ok(result.redactions[0].path.length > 0)
})
})
describe("hasSecrets", () => {
it("detects API keys", () => {
const content = "sk-1234567890abcdef"
assert.ok(hasSecrets(content))
})
it("detects bearer tokens", () => {
const content = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
assert.ok(hasSecrets(content))
})
it("returns false for normal content", () => {
const content = "This is a normal message"
assert.ok(!hasSecrets(content))
})
it("returns false for empty content", () => {
const content = ""
assert.ok(!hasSecrets(content))
})
})
describe("redactObject", () => {
it("redacts secrets in nested objects", () => {
const obj = {
apiKey: "sk-1234567890abcdef",
nested: {
token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
},
}
const result = redactObject(obj, "test")
assert.ok(!result.apiKey.includes("sk-1234567890abcdef"))
assert.ok(!result.nested.token.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
})
it("redacts secrets in arrays", () => {
const obj = {
messages: [
{ content: "Use sk-1234567890abcdef" },
{ content: "Normal message" },
],
}
const result = redactObject(obj, "test")
assert.ok(!result.messages[0].content.includes("sk-1234567890abcdef"))
assert.equal(result.messages[1].content, "Normal message")
})
it("preserves non-sensitive fields", () => {
const obj = {
name: "John Doe",
age: 30,
message: "Hello world",
}
const result = redactObject(obj, "test")
assert.equal(result.name, "John Doe")
assert.equal(result.age, 30)
assert.equal(result.message, "Hello world")
})
it("handles null and undefined values", () => {
const obj = {
value: null,
undefined: undefined,
message: "sk-1234567890abcdef",
}
const result = redactObject(obj, "test")
assert.equal(result.value, null)
assert.equal(result.undefined, undefined)
assert.ok(!result.message.includes("sk-1234567890abcdef"))
})
it("preserves object structure", () => {
const obj = {
level1: {
level2: {
level3: {
secret: "sk-1234567890abcdef",
},
},
},
}
const result = redactObject(obj, "test")
assert.ok(result.level1.level2.level3.secret)
assert.ok(!result.level1.level2.level3.secret.includes("sk-1234567890abcdef"))
})
})
})

View File

@@ -19,6 +19,13 @@ import type {
WorkspaceEventPayload,
WorkspaceEventType,
WorkspaceGitStatus,
WorkspaceExportRequest,
WorkspaceExportResponse,
WorkspaceImportRequest,
WorkspaceImportResponse,
WorkspaceMcpConfigRequest,
WorkspaceMcpConfigResponse,
PortAvailabilityResponse,
} from "../../../server/src/api-types"
import { getLogger } from "./logger"
@@ -158,6 +165,27 @@ export const serverApi = {
fetchWorkspaceGitStatus(id: string): Promise<WorkspaceGitStatus> {
return request<WorkspaceGitStatus>(`/api/workspaces/${encodeURIComponent(id)}/git/status`)
},
exportWorkspace(id: string, payload: WorkspaceExportRequest): Promise<WorkspaceExportResponse> {
return request<WorkspaceExportResponse>(`/api/workspaces/${encodeURIComponent(id)}/export`, {
method: "POST",
body: JSON.stringify(payload),
})
},
importWorkspace(payload: WorkspaceImportRequest): Promise<WorkspaceImportResponse> {
return request<WorkspaceImportResponse>("/api/workspaces/import", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchWorkspaceMcpConfig(id: string): Promise<WorkspaceMcpConfigResponse> {
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`)
},
updateWorkspaceMcpConfig(id: string, config: WorkspaceMcpConfigRequest["config"]): Promise<WorkspaceMcpConfigResponse> {
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`, {
method: "PUT",
body: JSON.stringify({ config }),
})
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
@@ -241,6 +269,9 @@ export const serverApi = {
const params = new URLSearchParams({ id })
return request<SkillDetail>(`/api/skills/detail?${params.toString()}`)
},
fetchAvailablePort(): Promise<PortAvailabilityResponse> {
return request<PortAvailabilityResponse>("/api/ports/available")
},
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -0,0 +1,168 @@
import { z } from "zod"
import { getLogger } from "./logger.js"
const log = getLogger("compaction-schema")
export const SecretRedactionSchema = z.object({
path: z.string(),
reason: z.string(),
})
export const ProvenanceSchema = z.object({
model: z.string().min(1, "Model name is required"),
token_count: z.number().int().nonnegative(),
redactions: z.array(SecretRedactionSchema),
})
export const KeyDecisionSchema = z.object({
id: z.string().min(1, "Decision ID is required"),
decision: z.string().min(1, "Decision is required"),
rationale: z.string().min(1, "Rationale is required"),
actor: z.enum(["agent", "user"], { errorMap: () => ({ message: "Actor must be 'agent' or 'user'" }) }),
})
export const ArtifactSchema = z.object({
type: z.string().min(1, "Artifact type is required"),
uri: z.string().min(1, "Artifact URI is required"),
notes: z.string(),
})
export const FileReferenceSchema = z.object({
path: z.string().min(1, "File path is required"),
notes: z.string(),
decision_id: z.string().min(1, "Decision ID is required"),
})
export const StructuredSummarySchema = z.object({
timestamp: z.string().datetime(),
summary_type: z.enum(["tierA_short", "tierB_detailed"]),
what_was_done: z.array(z.string()).min(1, "At least one 'what_was_done' entry is required"),
files: z.array(FileReferenceSchema).optional(),
current_state: z.string().min(1, "Current state is required"),
key_decisions: z.array(KeyDecisionSchema).optional(),
next_steps: z.array(z.string()).optional(),
blockers: z.array(z.string()).optional(),
artifacts: z.array(ArtifactSchema).optional(),
tags: z.array(z.string()).optional(),
provenance: ProvenanceSchema,
aggressive: z.boolean(),
})
export const CompactionEventSchema = z.object({
event_id: z.string().min(1, "Event ID is required"),
timestamp: z.string().datetime(),
actor: z.enum(["user", "auto"], { errorMap: () => ({ message: "Actor must be 'user' or 'auto'" }) }),
trigger_reason: z.enum(["overflow", "scheduled", "manual"]),
token_before: z.number().int().nonnegative(),
token_after: z.number().int().nonnegative(),
model_used: z.string().min(1, "Model name is required"),
cost_estimate: z.number().nonnegative(),
snapshot_id: z.string().optional(),
})
export const CompactionConfigSchema = z.object({
autoCompactEnabled: z.boolean(),
autoCompactThreshold: z.number().int().min(1).max(100),
compactPreserveWindow: z.number().int().positive(),
pruneReclaimThreshold: z.number().int().positive(),
userPreference: z.enum(["auto", "ask", "never"]),
undoRetentionWindow: z.number().int().positive(),
recentMessagesToKeep: z.number().int().positive().optional(),
systemMessagesToKeep: z.number().int().positive().optional(),
incrementalChunkSize: z.number().int().positive().optional(),
// ADK-style sliding window settings
compactionInterval: z.number().int().positive().optional(),
overlapSize: z.number().int().nonnegative().optional(),
enableAiSummarization: z.boolean().optional(),
summaryMaxTokens: z.number().int().positive().optional(),
preserveFileOperations: z.boolean().optional(),
preserveDecisions: z.boolean().optional(),
})
export const CompactionResultSchema = z.object({
success: z.boolean(),
mode: z.enum(["prune", "compact"]),
human_summary: z.string().min(1, "Human summary is required"),
detailed_summary: StructuredSummarySchema.optional(),
token_before: z.number().int().nonnegative(),
token_after: z.number().int().nonnegative(),
token_reduction_pct: z.number().int().min(0).max(100),
compaction_event: CompactionEventSchema.optional(),
preview: z.string().optional(),
})
export type SecretRedaction = z.infer<typeof SecretRedactionSchema>
export type Provenance = z.infer<typeof ProvenanceSchema>
export type KeyDecision = z.infer<typeof KeyDecisionSchema>
export type Artifact = z.infer<typeof ArtifactSchema>
export type FileReference = z.infer<typeof FileReferenceSchema>
export type StructuredSummary = z.infer<typeof StructuredSummarySchema>
export type CompactionEvent = z.infer<typeof CompactionEventSchema>
export type CompactionConfig = z.infer<typeof CompactionConfigSchema>
export type CompactionResult = z.infer<typeof CompactionResultSchema>
export function validateStructuredSummary(data: unknown): { success: true; data: StructuredSummary } | { success: false; errors: string[] } {
const result = StructuredSummarySchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function validateCompactionEvent(data: unknown): { success: true; data: CompactionEvent } | { success: false; errors: string[] } {
const result = CompactionEventSchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function validateCompactionResult(data: unknown): { success: true; data: CompactionResult } | { success: false; errors: string[] } {
const result = CompactionResultSchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function validateCompactionConfig(data: unknown): { success: true; data: CompactionConfig } | { success: false; errors: string[] } {
const result = CompactionConfigSchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function sanitizeStructuredSummary(input: unknown): StructuredSummary | null {
const result = validateStructuredSummary(input)
if (!result.success) {
log.warn("Invalid structured summary, using fallback", { errors: result.errors })
return null
}
return result.data
}
export function createDefaultStructuredSummary(aggressive: boolean = false): StructuredSummary {
return {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Session compaction completed"],
files: [],
current_state: "Session context has been compacted",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "system",
token_count: 0,
redactions: [],
},
aggressive,
}
}

View File

@@ -11,7 +11,7 @@ import {
getSessions,
setActiveSession,
} from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction"
import { compactSession } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
@@ -235,21 +235,9 @@ export function useCommands(options: UseCommandsOptions) {
const sessionId = activeSessionIdForInstance()
if (!instance || !instance.client || !sessionId || sessionId === "info") return
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return
try {
setSessionCompactionState(instance.id, sessionId, true)
await instance.client.session.summarize({
path: { id: sessionId },
body: {
providerID: session.model.providerId,
modelID: session.model.modelId,
},
})
await compactSession(instance.id, sessionId)
} catch (error) {
setSessionCompactionState(instance.id, sessionId, false)
log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session"
showAlertDialog(`Compact failed: ${message}`, {

View File

@@ -0,0 +1,286 @@
/**
* Qwen OAuth Chat Service
* Routes chat requests through the Qwen API using OAuth tokens
* Based on the qwen-code implementation from QwenLM/qwen-code
*/
import { getUserScopedKey } from "../user-storage"
const QWEN_TOKEN_STORAGE_KEY = 'qwen_oauth_token'
const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com'
export interface QwenToken {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
resource_url?: string
created_at: number
}
export interface QwenChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface QwenChatRequest {
model: string
messages: QwenChatMessage[]
stream?: boolean
temperature?: number
max_tokens?: number
}
export interface QwenChatResponse {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
message: {
role: string
content: string
}
finish_reason: string | null
}>
usage?: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
}
export interface QwenStreamChunk {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
delta: {
role?: string
content?: string
}
finish_reason: string | null
}>
}
/**
* Get stored Qwen OAuth token from localStorage
*/
export function getStoredQwenToken(): QwenToken | null {
try {
const stored = localStorage.getItem(getUserScopedKey(QWEN_TOKEN_STORAGE_KEY))
return stored ? JSON.parse(stored) : null
} catch {
return null
}
}
/**
* Check if Qwen OAuth token is valid and not expired
*/
export function isQwenTokenValid(token: QwenToken | null): boolean {
if (!token || !token.access_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 // 5 min buffer
return Date.now() < expiresAt
}
/**
* Get the API endpoint URL for Qwen
* Uses resource_url from token if available, otherwise falls back to default
*/
export function getQwenEndpoint(token: QwenToken | null): string {
const baseEndpoint = token?.resource_url || DEFAULT_QWEN_ENDPOINT
// Normalize URL: add protocol if missing
const normalizedUrl = baseEndpoint.startsWith('http')
? baseEndpoint
: `https://${baseEndpoint}`
// Ensure /v1 suffix for OpenAI-compatible API
return normalizedUrl.endsWith('/v1')
? normalizedUrl
: `${normalizedUrl}/v1`
}
/**
* Send a chat completion request to Qwen API
*/
export async function sendQwenChatRequest(
request: QwenChatRequest
): Promise<QwenChatResponse> {
const token = getStoredQwenToken()
if (!isQwenTokenValid(token)) {
throw new Error('Qwen OAuth token is invalid or expired. Please re-authenticate.')
}
const endpoint = getQwenEndpoint(token)
const url = `${endpoint}/chat/completions`
console.log(`[QwenChat] Sending request to: ${url}`)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token!.access_token}`,
'Accept': 'application/json'
},
body: JSON.stringify({
model: request.model || 'qwen-coder-plus-latest',
messages: request.messages,
stream: false,
temperature: request.temperature,
max_tokens: request.max_tokens
})
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[QwenChat] Request failed: ${response.status}`, errorText)
// Check for auth errors that require re-authentication
if (response.status === 401 || response.status === 403) {
throw new Error('Qwen OAuth token expired. Please re-authenticate using /auth.')
}
throw new Error(`Qwen chat request failed: ${response.status} - ${errorText}`)
}
return await response.json()
}
/**
* Send a streaming chat completion request to Qwen API
*/
export async function* sendQwenChatStreamRequest(
request: QwenChatRequest
): AsyncGenerator<QwenStreamChunk> {
const token = getStoredQwenToken()
if (!isQwenTokenValid(token)) {
throw new Error('Qwen OAuth token is invalid or expired. Please re-authenticate.')
}
const endpoint = getQwenEndpoint(token)
const url = `${endpoint}/chat/completions`
console.log(`[QwenChat] Sending streaming request to: ${url}`)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token!.access_token}`,
'Accept': 'text/event-stream'
},
body: JSON.stringify({
model: request.model || 'qwen-coder-plus-latest',
messages: request.messages,
stream: true,
temperature: request.temperature,
max_tokens: request.max_tokens
})
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[QwenChat] Stream request failed: ${response.status}`, errorText)
throw new Error(`Qwen chat request failed: ${response.status} - ${errorText}`)
}
if (!response.body) {
throw new Error('Response body is missing')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// Keep the last incomplete line in buffer
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed === 'data: [DONE]') {
continue
}
if (trimmed.startsWith('data: ')) {
try {
const data = JSON.parse(trimmed.slice(6))
yield data as QwenStreamChunk
} catch (e) {
console.warn('[QwenChat] Failed to parse SSE chunk:', trimmed)
}
}
}
}
} finally {
reader.releaseLock()
}
}
/**
* Get available Qwen models
*/
export async function getQwenModels(): Promise<{ id: string; name: string }[]> {
const token = getStoredQwenToken()
if (!isQwenTokenValid(token)) {
return []
}
const endpoint = getQwenEndpoint(token)
const url = `${endpoint}/models`
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token!.access_token}`,
'Accept': 'application/json'
}
})
if (!response.ok) {
console.warn(`[QwenChat] Failed to fetch models: ${response.status}`)
return getDefaultQwenModels()
}
const data = await response.json()
return (data.data || []).map((model: any) => ({
id: model.id,
name: model.id
}))
} catch (error) {
console.warn('[QwenChat] Error fetching models:', error)
return getDefaultQwenModels()
}
}
/**
* Get default Qwen models when API call fails
*/
function getDefaultQwenModels(): { id: string; name: string }[] {
return [
{ id: 'qwen-coder-plus-latest', name: 'Qwen Coder Plus' },
{ id: 'qwen-turbo-latest', name: 'Qwen Turbo' },
{ id: 'qwen-plus-latest', name: 'Qwen Plus' },
{ id: 'qwen-max-latest', name: 'Qwen Max' }
]
}

View File

@@ -4,8 +4,8 @@
*/
import { nanoid } from 'nanoid'
import type { AxiosInstance, AxiosResponse } from 'axios'
import { createSignal, onMount } from 'solid-js'
import { getUserScopedKey } from "../user-storage"
// Configuration schema
export interface QwenConfig {
@@ -13,6 +13,7 @@ export interface QwenConfig {
redirectUri?: string
scope?: string
baseUrl?: string
apiBaseUrl?: string
}
export interface QwenAuthToken {
@@ -22,6 +23,7 @@ export interface QwenAuthToken {
refresh_token?: string
scope?: string
created_at: number
resource_url?: string
}
export interface QwenUser {
@@ -43,82 +45,77 @@ export interface QwenOAuthState {
redirect_uri: string
}
function toBase64Url(bytes: Uint8Array): string {
let binary = ''
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export class QwenOAuthManager {
private config: Required<QwenConfig>
private tokenStorageKey = 'qwen_oauth_token'
private userStorageKey = 'qwen_user_info'
private config: { clientId: string; redirectUri: string; scope: string; baseUrl: string }
private tokenStorageKey = getUserScopedKey('qwen_oauth_token')
private userStorageKey = getUserScopedKey('qwen_user_info')
constructor(config: QwenConfig = {}) {
this.config = {
clientId: config.clientId || 'qwen-code-client',
redirectUri: config.redirectUri || `${window.location.origin}/auth/qwen/callback`,
scope: config.scope || 'read write',
baseUrl: config.baseUrl || 'https://qwen.ai'
scope: config.scope || 'openid profile email model.completion',
baseUrl: config.apiBaseUrl || config.baseUrl || ''
}
}
/**
* Generate OAuth URL for authentication
* Request device authorization for Qwen OAuth
*/
async generateAuthUrl(): Promise<{ url: string; state: QwenOAuthState }> {
const state = await this.generateOAuthState()
const params = new URLSearchParams({
response_type: 'code',
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
scope: this.config.scope,
state: state.state,
code_challenge: state.code_challenge,
code_challenge_method: 'S256'
})
const authUrl = `${this.config.baseUrl}/oauth/authorize?${params.toString()}`
return {
url: authUrl,
state
}
}
/**
* Exchange authorization code for access token
*/
async exchangeCodeForToken(code: string, state: string): Promise<QwenAuthToken> {
const storedState = this.getOAuthState(state)
if (!storedState) {
throw new Error('Invalid OAuth state')
}
try {
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
async requestDeviceAuthorization(codeChallenge: string): Promise<{
device_code: string
user_code: string
verification_uri: string
verification_uri_complete: string
expires_in: number
}> {
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/device`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Type': 'application/json'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.config.clientId,
code,
redirect_uri: this.config.redirectUri,
code_verifier: storedState.code_verifier
body: JSON.stringify({
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
})
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`)
const message = await response.text()
throw new Error(`Device authorization failed: ${message}`)
}
const tokenData = await response.json()
const token = this.parseTokenResponse(tokenData)
return await response.json()
}
// Store token
this.storeToken(token)
this.clearOAuthState(state)
/**
* Poll device token endpoint
*/
async pollDeviceToken(deviceCode: string, codeVerifier: string): Promise<any> {
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
device_code: deviceCode,
code_verifier: codeVerifier
})
})
return token
} catch (error) {
this.clearOAuthState(state)
throw error
const rawText = await response.text()
try {
return JSON.parse(rawText)
} catch {
throw new Error(`Token poll failed: ${rawText}`)
}
}
@@ -132,14 +129,12 @@ export class QwenOAuthManager {
}
try {
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Type': 'application/json',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.config.clientId,
body: JSON.stringify({
refresh_token: currentToken.refresh_token
})
})
@@ -169,17 +164,28 @@ export class QwenOAuthManager {
throw new Error('Not authenticated')
}
const response = await fetch(`${this.config.baseUrl}/api/user`, {
try {
const response = await fetch(`/api/qwen/user`, {
headers: {
'Authorization': `Bearer ${token.access_token}`
}
})
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`)
}
return await response.json()
const data = await response.json()
return data.user || data
} catch {
return {
id: 'qwen-oauth',
username: 'Qwen OAuth',
tier: 'Free',
limits: {
requests_per_day: 0,
requests_per_minute: 0
}
}
}
}
/**
@@ -191,11 +197,7 @@ export class QwenOAuthManager {
return null
}
// Check if token is expired (with 5-minute buffer)
const now = Date.now()
const expiresAt = (token.created_at + token.expires_in) * 1000 - 300000 // 5 min buffer
if (now >= expiresAt) {
if (this.isTokenExpired(token)) {
try {
return await this.refreshToken()
} catch (error) {
@@ -207,37 +209,6 @@ export class QwenOAuthManager {
return token
}
/**
* Create authenticated HTTP client
*/
createApiClient(): AxiosInstance {
const axios = require('axios') as any
return axios.create({
baseURL: `${this.config.baseUrl}/api`,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
}
/**
* Make authenticated API request
*/
async makeAuthenticatedRequest<T>(
client: AxiosInstance,
config: any
): Promise<AxiosResponse<T>> {
const token = await this.getValidToken()
if (!token) {
throw new Error('Authentication required')
}
client.defaults.headers.common['Authorization'] = `Bearer ${token.access_token}`
return client.request(config)
}
/**
* Sign out user
*/
@@ -250,8 +221,9 @@ export class QwenOAuthManager {
* Check if user is authenticated
*/
isAuthenticated(): boolean {
const token = this.getValidToken()
return token !== null
const token = this.getStoredToken()
if (!token) return false
return !this.isTokenExpired(token)
}
/**
@@ -269,7 +241,7 @@ export class QwenOAuthManager {
/**
* Store user info
*/
private storeUserInfo(user: QwenUser): void {
storeUserInfo(user: QwenUser): void {
localStorage.setItem(this.userStorageKey, JSON.stringify(user))
}
@@ -323,38 +295,34 @@ export class QwenOAuthManager {
/**
* Generate code verifier for PKCE
*/
private generateCodeVerifier(): string {
generateCodeVerifier(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return Array.from(array, byte => String.fromCharCode(byte)).join('')
return toBase64Url(array)
}
/**
* Generate code challenge for PKCE
*/
private async generateCodeChallenge(verifier: string): Promise<string> {
async generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(digest))
.map(b => String.fromCharCode(b))
.join('')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return toBase64Url(new Uint8Array(digest))
}
/**
* Parse token response
*/
private parseTokenResponse(data: any): QwenAuthToken {
parseTokenResponse(data: any): QwenAuthToken {
const token: QwenAuthToken = {
access_token: data.access_token,
token_type: data.token_type,
expires_in: data.expires_in,
refresh_token: data.refresh_token,
scope: data.scope,
created_at: Date.now()
resource_url: data.resource_url,
created_at: Math.floor(Date.now() / 1000)
}
return token
@@ -363,7 +331,7 @@ export class QwenOAuthManager {
/**
* Store token
*/
private storeToken(token: QwenAuthToken): void {
storeToken(token: QwenAuthToken): void {
localStorage.setItem(this.tokenStorageKey, JSON.stringify(token))
}
@@ -379,6 +347,16 @@ export class QwenOAuthManager {
}
}
getTokenInfo(): QwenAuthToken | null {
return this.getStoredToken()
}
private isTokenExpired(token: QwenAuthToken): boolean {
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
}
/**
* Clear token
*/
@@ -393,70 +371,82 @@ export function useQwenOAuth(config?: QwenConfig) {
const [isAuthenticated, setIsAuthenticated] = createSignal(false)
const [user, setUser] = createSignal<QwenUser | null>(null)
const [isLoading, setIsLoading] = createSignal(false)
const [tokenInfo, setTokenInfo] = createSignal<QwenAuthToken | null>(null)
// Check authentication status on mount
onMount(() => {
const manager = authManager()
if (manager.isAuthenticated()) {
manager.getValidToken().then((token) => {
if (!token) return
setIsAuthenticated(true)
setTokenInfo(manager.getTokenInfo())
const userInfo = manager.getUserInfo()
if (userInfo) {
setUser(userInfo)
}
}
}).catch(() => {
setIsAuthenticated(false)
})
})
const signIn = async () => {
setIsLoading(true)
try {
const manager = authManager()
const { url, state } = await manager.generateAuthUrl()
const codeVerifier = manager.generateCodeVerifier()
const codeChallenge = await manager.generateCodeChallenge(codeVerifier)
const deviceAuth = await manager.requestDeviceAuthorization(codeChallenge)
// Open popup window for OAuth
const popup = window.open(
url,
deviceAuth.verification_uri_complete,
'qwen-oauth',
'width=500,height=600,scrollbars=yes,resizable=yes'
)
if (!popup) {
throw new Error('Failed to open OAuth popup')
window.alert(
`Open this URL to authenticate: ${deviceAuth.verification_uri_complete}\n\nUser code: ${deviceAuth.user_code}`,
)
}
// Listen for popup close
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed)
setIsLoading(false)
}
}, 1000)
const expiresAt = Date.now() + deviceAuth.expires_in * 1000
let pollInterval = 2000
// Listen for message from popup
const messageListener = async (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
while (Date.now() < expiresAt) {
const tokenData = await manager.pollDeviceToken(deviceAuth.device_code, codeVerifier)
if (event.data.type === 'QWEN_OAUTH_SUCCESS') {
const { code, state } = event.data
await manager.exchangeCodeForToken(code, state)
if (tokenData?.access_token) {
const token = manager.parseTokenResponse(tokenData)
manager.storeToken(token)
setTokenInfo(manager.getTokenInfo())
const userInfo = await manager.fetchUserInfo()
if (userInfo) {
manager.storeUserInfo(userInfo)
setUser(userInfo)
} else {
setUser(null)
}
setIsAuthenticated(true)
setIsLoading(false)
popup.close()
} else if (event.data.type === 'QWEN_OAUTH_ERROR') {
setIsLoading(false)
popup.close()
}
popup?.close()
return
}
window.addEventListener('message', messageListener)
if (tokenData?.error === 'authorization_pending') {
await new Promise((resolve) => setTimeout(resolve, pollInterval))
continue
}
// Cleanup
setTimeout(() => {
clearInterval(checkClosed)
window.removeEventListener('message', messageListener)
setIsLoading(false)
}, 300000) // 5 minute timeout
if (tokenData?.error === 'slow_down') {
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), 10000)
await new Promise((resolve) => setTimeout(resolve, pollInterval))
continue
}
throw new Error(tokenData?.error_description || tokenData?.error || 'OAuth failed')
}
throw new Error('OAuth timed out')
} catch (error) {
setIsLoading(false)
@@ -469,18 +459,15 @@ export function useQwenOAuth(config?: QwenConfig) {
manager.signOut()
setIsAuthenticated(false)
setUser(null)
}
const createApiClient = () => {
return authManager().createApiClient()
setTokenInfo(null)
}
return {
isAuthenticated: () => isAuthenticated(),
user: () => user(),
isLoading: () => isLoading(),
tokenInfo: () => tokenInfo(),
signIn,
signOut,
createApiClient
signOut
}
}

View File

@@ -0,0 +1,225 @@
import { getLogger } from "./logger.js"
const log = getLogger("secrets-detector")
export interface SecretMatch {
type: string
value: string
start: number
end: number
reason: string
}
export interface RedactionResult {
clean: string
redactions: { path: string; reason: string }[]
}
export interface SecretPattern {
name: string
pattern: RegExp
reason: string
}
const SECRET_PATTERNS: SecretPattern[] = [
{
name: "api_key",
pattern: /['"]?api[_-]?key['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{20,})['"]?/gi,
reason: "API key detected",
},
{
name: "bearer_token",
pattern: /bearer\s+([a-zA-Z0-9_-]{30,})/gi,
reason: "Bearer token detected",
},
{
name: "jwt_token",
pattern: /eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g,
reason: "JWT token detected",
},
{
name: "aws_access_key",
pattern: /AKIA[0-9A-Z]{16}/g,
reason: "AWS access key detected",
},
{
name: "aws_secret_key",
pattern: /['"]?aws[_-]?secret[_-]?access[_-]?key['"]?\s*[:=]\s*['"]?([a-zA-Z0-9/+]{40})['"]?/gi,
reason: "AWS secret key detected",
},
{
name: "private_key",
pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(RSA\s+)?PRIVATE\s+KEY-----/gi,
reason: "Private key detected",
},
{
name: "password",
pattern: /['"]?(password|passwd|pwd)['"]?\s*[:=]\s*['"]?([^'\s"]{8,})['"]?/gi,
reason: "Password field detected",
},
{
name: "secret",
pattern: /['"]?(secret|api[_-]?secret)['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{16,})['"]?/gi,
reason: "Secret field detected",
},
{
name: "token",
pattern: /['"]?(token|access[_-]?token|auth[_-]?token)['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{30,})['"]?/gi,
reason: "Auth token detected",
},
{
name: "github_token",
pattern: /gh[pous]_[a-zA-Z0-9]{36}/g,
reason: "GitHub token detected",
},
{
name: "openai_key",
pattern: /sk-[a-zA-Z0-9]{48}/g,
reason: "OpenAI API key detected",
},
{
name: "database_url",
pattern: /(mongodb|postgres|mysql|redis):\/\/[^\s'"]+/gi,
reason: "Database connection URL detected",
},
{
name: "credit_card",
pattern: /\b(?:\d[ -]*?){13,16}\b/g,
reason: "Potential credit card number detected",
},
{
name: "email",
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
reason: "Email address detected",
},
{
name: "ip_address",
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
reason: "IP address detected",
},
]
const REPLACEMENT_PLACEHOLDER = "[REDACTED]"
function detectSecrets(content: string): SecretMatch[] {
const matches: SecretMatch[] = []
for (const pattern of SECRET_PATTERNS) {
let match
const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags)
while ((match = regex.exec(content)) !== null) {
matches.push({
type: pattern.name,
value: match[0],
start: match.index,
end: match.index + match[0].length,
reason: pattern.reason,
})
}
}
return matches.sort((a, b) => a.start - b.start)
}
function mergeOverlappingMatches(matches: SecretMatch[]): SecretMatch[] {
if (matches.length === 0) return []
const merged: SecretMatch[] = [matches[0]]
for (let i = 1; i < matches.length; i++) {
const current = matches[i]
const last = merged[merged.length - 1]
if (current.start <= last.end) {
last.end = Math.max(last.end, current.end)
if (!last.reason.includes(current.reason)) {
last.reason += ` | ${current.reason}`
}
} else {
merged.push(current)
}
}
return merged
}
export function redactSecrets(content: string, contextPath: string = "unknown"): RedactionResult {
if (!content || typeof content !== "string") {
return { clean: content, redactions: [] }
}
const rawMatches = detectSecrets(content)
const mergedMatches = mergeOverlappingMatches(rawMatches)
if (mergedMatches.length === 0) {
return { clean: content, redactions: [] }
}
let result = ""
let lastIndex = 0
const redactions: { path: string; reason: string }[] = []
for (const match of mergedMatches) {
result += content.slice(lastIndex, match.start)
result += REPLACEMENT_PLACEHOLDER
lastIndex = match.end
redactions.push({
path: `${contextPath}[${match.start}:${match.end}]`,
reason: match.reason,
})
}
result += content.slice(lastIndex)
log.info("Redacted secrets", { contextPath, count: redactions.length, types: mergedMatches.map((m) => m.type) })
return { clean: result, redactions }
}
export function hasSecrets(content: string): boolean {
if (!content || typeof content !== "string") {
return false
}
return SECRET_PATTERNS.some((pattern) => pattern.pattern.test(content))
}
export function redactObject(obj: any, contextPath: string = "root"): any {
if (obj === null || obj === undefined) {
return obj
}
if (typeof obj === "string") {
const result = redactSecrets(obj, contextPath)
return result.clean
}
if (Array.isArray(obj)) {
return obj.map((item, index) => redactObject(item, `${contextPath}[${index}]`))
}
if (typeof obj === "object") {
const result: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = redactObject(obj[key], `${contextPath}.${key}`)
}
}
return result
}
return obj
}
export function getSecretsReport(content: string): { total: number; byType: Record<string, number> } {
const matches = detectSecrets(content)
const byType: Record<string, number> = {}
for (const match of matches) {
byType[match.type] = (byType[match.type] || 0) + 1
}
return { total: matches.length, byType }
}

View File

@@ -1,4 +1,8 @@
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
export type SessionSidebarRequestAction =
| "focus-agent-selector"
| "focus-model-selector"
| "show-session-list"
| "show-skills"
export interface SessionSidebarRequestDetail {
instanceId: string

View File

@@ -0,0 +1,7 @@
import { activeUser } from "../stores/users"
export function getUserScopedKey(baseKey: string): string {
const userId = activeUser()?.id
if (!userId) return baseKey
return `${baseKey}:${userId}`
}

View File

@@ -1,13 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<title>NomadArch</title>
<style>
:root {
color-scheme: dark;
}
html,
body {
background-color: #1a1a1a;
@@ -25,8 +27,10 @@
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -29,12 +29,14 @@ button {
max-width: 520px;
width: 100%;
text-align: center;
animation: fadeIn 0.4s ease-out;
}
.loading-logo {
width: 180px;
height: auto;
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
animation: logoPulse 3s ease-in-out infinite;
}
.loading-heading {
@@ -54,6 +56,7 @@ button {
margin: 0;
font-size: 1rem;
color: var(--text-muted, #aeb3c4);
animation: fadeIn 0.3s ease-out;
}
.loading-card {
@@ -64,7 +67,13 @@ button {
border-radius: 18px;
background: rgba(13, 16, 24, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(108, 227, 255, 0.05);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.loading-card:hover {
border-color: rgba(108, 227, 255, 0.15);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(108, 227, 255, 0.1);
}
.loading-row {
@@ -81,7 +90,8 @@ button {
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
animation: spin 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
box-shadow: 0 0 10px rgba(108, 227, 255, 0.3);
}
.phrase-controls {
@@ -93,12 +103,29 @@ button {
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
padding: 4px 12px;
border-radius: 6px;
transition: all 0.2s ease;
}
.phrase-controls button:hover {
background: rgba(143, 181, 255, 0.1);
transform: translateY(-1px);
}
.phrase-controls button:active {
transform: translateY(0);
}
.loading-error {
margin-top: 12px;
padding: 12px 16px;
background: rgba(255, 94, 109, 0.1);
border: 1px solid rgba(255, 94, 109, 0.2);
border-radius: 8px;
color: #ff9ea9;
font-size: 0.95rem;
font-size: 0.9rem;
animation: fadeIn 0.3s ease-out;
}
@keyframes spin {
@@ -109,3 +136,23 @@ button {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes logoPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}

View File

@@ -1,6 +1,6 @@
import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web"
import iconUrl from "../../images/CodeNomad-Icon.png"
import iconUrl from "../../images/NomadArch-Icon.png"
import { runtimeEnv, isTauriHost } from "../../lib/runtime-env"
import "../../index.css"
import "./loading.css"
@@ -202,7 +202,7 @@ function LoadingApp() {
<img src={iconUrl} alt="NomadArch" class="loading-logo" width="180" height="180" />
<div class="loading-heading">
<h1 class="loading-title">NomadArch 1.0</h1>
<p class="loading-subtitle" style={{ fontSize: '14px', color: '#666', marginTop: '4px' }}>A fork of OpenCode</p>
<p class="loading-subtitle" style={{ "font-size": '14px', "color": '#666', "margin-top": '4px' }}>A fork of OpenCode</p>
<Show when={status()}>{(statusText) => <p class="loading-status">{statusText()}</p>}</Show>
</div>
<div class="loading-card">

View File

@@ -0,0 +1,273 @@
import assert from "node:assert/strict"
import { beforeEach, describe, it, mock } from "node:test"
import type { CompactionResult } from "../session-compaction.js"
import {
getCompactionConfig,
updateCompactionConfig,
undoCompaction,
rehydrateSession,
checkAndTriggerAutoCompact,
setSessionCompactionState,
getSessionCompactionState,
estimateTokenReduction,
executeCompactionWrapper,
} from "../session-compaction.js"
import type { CompactionEvent, StructuredSummary } from "../../lib/compaction-schema.js"
const MOCK_INSTANCE_ID = "test-instance-123"
const MOCK_SESSION_ID = "test-session-456"
const MOCK_MESSAGE_ID = "msg-789"
function createMockMessage(id: string, content: string = "Test message"): any {
return {
id,
sessionId: MOCK_SESSION_ID,
role: "user",
content,
status: "complete",
parts: [{ id: `part-${id}`, type: "text", text: content, sessionID: MOCK_SESSION_ID, messageID: id }],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
function createMockUsage(tokens: number = 10000): any {
return {
totalInputTokens: Math.floor(tokens * 0.7),
totalOutputTokens: Math.floor(tokens * 0.2),
totalReasoningTokens: Math.floor(tokens * 0.1),
}
}
describe("session compaction", () => {
beforeEach(() => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "auto",
undoRetentionWindow: 5,
})
})
describe("getCompactionConfig", () => {
it("returns default config", () => {
const config = getCompactionConfig()
assert.equal(typeof config.autoCompactEnabled, "boolean")
assert.equal(typeof config.autoCompactThreshold, "number")
assert.equal(typeof config.compactPreserveWindow, "number")
assert.equal(typeof config.pruneReclaimThreshold, "number")
assert.equal(typeof config.userPreference, "string")
assert.equal(typeof config.undoRetentionWindow, "number")
})
it("allows config updates", () => {
updateCompactionConfig({
autoCompactEnabled: false,
autoCompactThreshold: 80,
compactPreserveWindow: 4000,
pruneReclaimThreshold: 8000,
userPreference: "ask",
undoRetentionWindow: 10,
})
const config = getCompactionConfig()
assert.equal(config.autoCompactEnabled, false)
assert.equal(config.autoCompactThreshold, 80)
assert.equal(config.userPreference, "ask")
assert.equal(config.undoRetentionWindow, 10)
})
})
describe("setSessionCompactionState and getSessionCompactionState", () => {
it("tracks compaction state for sessions", () => {
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(isCompacting)
})
it("returns undefined for unknown sessions", () => {
const isCompacting = getSessionCompactionState("unknown-instance", "unknown-session")
assert.equal(isCompacting, undefined)
})
it("clears compaction state", () => {
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, false)
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!isCompacting)
})
})
describe("estimateTokenReduction", () => {
it("calculates correct percentage reduction", () => {
const reduction = estimateTokenReduction(10000, 3000)
assert.equal(reduction, 70)
})
it("returns 0 when no reduction", () => {
const reduction = estimateTokenReduction(10000, 10000)
assert.equal(reduction, 0)
})
it("handles zero tokens", () => {
const reduction = estimateTokenReduction(0, 0)
assert.equal(reduction, 0)
})
it("caps at 100%", () => {
const reduction = estimateTokenReduction(10000, -5000)
assert.equal(reduction, 100)
})
it("handles small values", () => {
const reduction = estimateTokenReduction(100, 50)
assert.equal(reduction, 50)
})
})
describe("executeCompactionWrapper", () => {
it("compacts session successfully", async () => {
const mockStore = {
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getSessionUsage: () => createMockUsage(10000),
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
assert.ok(result.success)
assert.equal(result.mode, "compact")
assert.ok(result.token_before > 0)
assert.ok(result.token_after >= 0)
assert.ok(result.token_reduction_pct >= 0)
assert.ok(result.human_summary.length > 0)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("handles missing instance", async () => {
const getInstanceMock = mock.fn(() => null)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
assert.ok(!result.success)
assert.equal(result.human_summary, "Instance not found")
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("handles prune mode", async () => {
const mockStore = {
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getSessionUsage: () => createMockUsage(10000),
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "prune")
assert.ok(result.success)
assert.equal(result.mode, "prune")
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
})
describe("checkAndTriggerAutoCompact", () => {
it("does not trigger when user preference is never", async () => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "never",
undoRetentionWindow: 5,
})
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!shouldCompact)
})
it("returns false when no overflow", async () => {
const mockStore = {
getSessionUsage: () => createMockUsage(50000),
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!shouldCompact)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("triggers auto-compact when enabled", async () => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "auto",
undoRetentionWindow: 5,
})
const mockStore = {
getSessionUsage: () => createMockUsage(120000),
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(shouldCompact)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
})
})

View File

@@ -10,6 +10,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
agentModelSelections: {},
sessionTasks: {},
sessionSkills: {},
customAgents: [],
}
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
@@ -24,6 +25,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
sessionTasks: { ...(source.sessionTasks ?? {}) },
sessionSkills: { ...(source.sessionSkills ?? {}) },
customAgents: Array.isArray(source.customAgents) ? [...source.customAgents] : [],
}
}

View File

@@ -87,3 +87,20 @@ class MessageStoreBus {
}
export const messageStoreBus = new MessageStoreBus()
export async function archiveMessages(instanceId: string, sessionId: string, keepLastN: number = 2): Promise<void> {
const store = messageStoreBus.getInstance(instanceId)
if (!store) return
const messageIds = store.getSessionMessageIds(sessionId)
if (messageIds.length <= keepLastN) return
const messagesToArchive = messageIds.slice(0, -keepLastN)
const archiveId = `archived_${sessionId}_${Date.now()}`
for (const messageId of messagesToArchive) {
store.setMessageInfo(messageId, { archived: true } as any)
}
log.info("Archived messages", { instanceId, sessionId, count: messagesToArchive.length, archiveId })
}

View File

@@ -41,7 +41,7 @@ function ensureVisibilityEffect() {
if (!activeToast || activeToastVersion !== release.version) {
dismissActiveToast()
activeToast = showToastNotification({
title: `CodeNomad ${release.version}`,
title: `NomadArch ${release.version}`,
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
variant: "info",
duration: Number.POSITIVE_INFINITY,

View File

@@ -9,12 +9,20 @@ import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus"
import { buildRecordDisplayData } from "./message-v2/record-display-cache"
import { getLogger } from "../lib/logger"
import { executeCompactionWrapper, getSessionCompactionState, setSessionCompactionState, type CompactionResult } from "./session-compaction"
import {
executeCompactionWrapper,
getSessionCompactionState,
setSessionCompactionState,
setCompactionSuggestion,
clearCompactionSuggestion,
type CompactionResult,
} from "./session-compaction"
import { createSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { showConfirmDialog } from "./alerts"
import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
import { getUserScopedKey } from "../lib/user-storage"
import { loadSkillDetails } from "./skills"
import { serverApi } from "../lib/api-client"
const log = getLogger("actions")
@@ -28,16 +36,18 @@ const COMPACTION_ATTEMPT_TTL_MS = 60_000
const COMPACTION_SUMMARY_MAX_CHARS = 4000
const STREAM_TIMEOUT_MS = 120_000
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
function markOpencodeZenModelOffline(modelId: string): void {
if (typeof window === "undefined" || !modelId) return
try {
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const key = getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const raw = window.localStorage.getItem(key)
const parsed = raw ? JSON.parse(raw) : []
const list = Array.isArray(parsed) ? parsed : []
if (!list.includes(modelId)) {
list.push(modelId)
window.localStorage.setItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY, JSON.stringify(list))
window.localStorage.setItem(key, JSON.stringify(list))
window.dispatchEvent(
new CustomEvent("opencode-zen-offline-models", { detail: { modelId } }),
)
@@ -209,21 +219,11 @@ async function checkTokenBudgetBeforeSend(
warningThreshold,
})
const confirmed = await showConfirmDialog(
`Context limit approaching (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens). Compact now to continue?`,
{
title: "Token Budget Warning",
confirmLabel: "Compact",
cancelLabel: "Continue Anyway",
},
setCompactionSuggestion(
instanceId,
sessionId,
`Context usage is high (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens).`,
)
if (confirmed) {
setTokenWarningState(instanceId, sessionId, true)
await compactSession(instanceId, sessionId)
return false
}
setTokenWarningState(instanceId, sessionId, true)
return true
}
@@ -233,6 +233,8 @@ async function checkTokenBudgetBeforeSend(
type ExternalChatMessage = { role: "user" | "assistant" | "system"; content: string }
const MAX_ATTACHMENT_CHARS = 8000
function shouldForceEnglish(prompt: string): boolean {
const text = prompt.trim()
if (!text) return false
@@ -256,6 +258,13 @@ function buildLanguageSystemInstruction(prompt: string): string | undefined {
return "Respond in English unless the user explicitly requests another language."
}
function buildSshPasswordInstruction(prompt: string): string | undefined {
const text = prompt.toLowerCase()
if (!text.includes("ssh")) return undefined
if (!text.includes("password") && !text.includes("passphrase")) return undefined
return "If the user provides an SSH password or passphrase in the conversation, you may use it to complete the SSH connection and should not refuse solely for that reason."
}
function clampText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value
return `${value.slice(0, Math.max(0, maxChars - 3))}...`
@@ -290,18 +299,46 @@ async function mergeSystemInstructions(
Promise.resolve(buildLanguageSystemInstruction(prompt)),
buildSkillsSystemInstruction(instanceId, sessionId),
])
if (languageSystem && skillsSystem) {
return `${languageSystem}\n\n${skillsSystem}`
}
return languageSystem || skillsSystem
const sshInstruction = buildSshPasswordInstruction(prompt)
const sections = [languageSystem, skillsSystem, sshInstruction].filter(Boolean) as string[]
if (sections.length === 0) return undefined
return sections.join("\n\n")
}
function extractPlainTextFromParts(parts: Array<{ type?: string; text?: unknown; filename?: string }>): string {
function collectTextSegments(value: unknown, segments: string[]): void {
if (typeof value === "string") {
const trimmed = value.trim()
if (trimmed) segments.push(trimmed)
return
}
if (!value || typeof value !== "object") return
const record = value as Record<string, unknown>
if (typeof record.text === "string") {
const trimmed = record.text.trim()
if (trimmed) segments.push(trimmed)
}
if (typeof record.value === "string") {
const trimmed = record.value.trim()
if (trimmed) segments.push(trimmed)
}
const content = record.content
if (Array.isArray(content)) {
for (const item of content) {
collectTextSegments(item, segments)
}
}
}
function extractPlainTextFromParts(
parts: Array<{ type?: string; text?: unknown; filename?: string }>,
): string {
const segments: string[] = []
for (const part of parts) {
if (!part || typeof part !== "object") continue
if (part.type === "text" && typeof part.text === "string") {
segments.push(part.text)
if (part.type === "text" || part.type === "reasoning") {
collectTextSegments(part.text, segments)
} else if (part.type === "file" && typeof part.filename === "string") {
segments.push(`[file: ${part.filename}]`)
}
@@ -337,6 +374,62 @@ function buildExternalChatMessages(
return messages
}
function decodeAttachmentData(data: Uint8Array): string {
const decoder = new TextDecoder()
return decoder.decode(data)
}
function isTextLikeMime(mime?: string): boolean {
if (!mime) return false
if (mime.startsWith("text/")) return true
return ["application/json", "application/xml", "application/x-yaml"].includes(mime)
}
async function buildExternalChatMessagesWithAttachments(
instanceId: string,
sessionId: string,
systemMessage: string | undefined,
attachments: Array<{ filename?: string; source?: any; mediaType?: string }>,
): Promise<ExternalChatMessage[]> {
const baseMessages = buildExternalChatMessages(instanceId, sessionId, systemMessage)
if (!attachments || attachments.length === 0) {
return baseMessages
}
const attachmentMessages: ExternalChatMessage[] = []
for (const attachment of attachments) {
const source = attachment?.source
if (!source || typeof source !== "object") continue
let content: string | null = null
if (source.type === "text" && typeof source.value === "string") {
content = source.value
} else if (source.type === "file") {
if (source.data instanceof Uint8Array && isTextLikeMime(source.mime || attachment.mediaType)) {
content = decodeAttachmentData(source.data)
} else if (typeof source.path === "string" && source.path.length > 0) {
try {
const response = await serverApi.readWorkspaceFile(instanceId, source.path)
content = typeof response.contents === "string" ? response.contents : null
} catch {
content = null
}
}
}
if (!content) continue
const filename = attachment.filename || source.path || "attachment"
const trimmed = clampText(content, MAX_ATTACHMENT_CHARS)
attachmentMessages.push({
role: "user",
content: `Attachment: ${filename}\n\n${trimmed}`,
})
}
return [...baseMessages, ...attachmentMessages]
}
async function readSseStream(
response: Response,
onData: (data: string) => void,
@@ -396,7 +489,7 @@ async function streamOllamaChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -410,7 +503,7 @@ async function streamOllamaChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -477,7 +570,7 @@ async function streamQwenChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
accessToken: string,
resourceUrl: string | undefined,
messageId: string,
@@ -496,7 +589,7 @@ async function streamQwenChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
resource_url: resourceUrl,
}),
@@ -561,7 +654,7 @@ async function streamOpenCodeZenChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -575,7 +668,7 @@ async function streamOpenCodeZenChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -645,7 +738,7 @@ async function streamZAIChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -659,7 +752,7 @@ async function streamZAIChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -868,6 +961,12 @@ async function sendMessage(
const now = Date.now()
const assistantMessageId = createId("msg")
const assistantPartId = createId("part")
const externalMessages = await buildExternalChatMessagesWithAttachments(
instanceId,
sessionId,
systemMessage,
attachments,
)
store.upsertMessage({
id: assistantMessageId,
@@ -902,7 +1001,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -913,7 +1012,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -924,7 +1023,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -962,7 +1061,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
token.access_token,
token.resource_url,
messageId,
@@ -1151,12 +1250,29 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
}
const agent = session.agent || "build"
let resolvedCommand = command
if (command.trim() === "build") {
try {
const response = await serverApi.fetchAvailablePort()
if (response?.port) {
const isWindows = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent)
resolvedCommand = isWindows ? `set PORT=${response.port}&& ${command}` : `PORT=${response.port} ${command}`
if (typeof window !== "undefined") {
const url = `http://localhost:${response.port}`
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, { detail: { url, instanceId } }))
}
}
} catch (error) {
log.warn("Failed to resolve available port for build", { error })
}
}
await instance.client.session.shell({
path: { id: sessionId },
body: {
agent,
command,
command: resolvedCommand,
},
})
}
@@ -1310,6 +1426,7 @@ async function compactSession(instanceId: string, sessionId: string): Promise<Co
})
log.info("compactSession: Complete", { instanceId, sessionId, compactedSessionId: compactedSession.id })
clearCompactionSuggestion(instanceId, sessionId)
return {
...result,
token_before: tokenBefore,
@@ -1407,6 +1524,30 @@ async function updateSessionModel(
updateSessionInfo(instanceId, sessionId)
}
async function updateSessionModelForSession(
instanceId: string,
sessionId: string,
model: { providerId: string; modelId: string },
): Promise<void> {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
if (!isModelValid(instanceId, model)) {
log.warn("Invalid model selection", model)
return
}
withSession(instanceId, sessionId, (current) => {
current.model = model
})
addRecentModelPreference(model)
updateSessionInfo(instanceId, sessionId)
}
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
@@ -1500,4 +1641,5 @@ export {
sendMessage,
updateSessionAgent,
updateSessionModel,
updateSessionModelForSession,
}

View File

@@ -33,6 +33,7 @@ import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { showToastNotification } from "../lib/notifications"
import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("api")
@@ -147,7 +148,7 @@ function getStoredQwenToken():
| null {
if (typeof window === "undefined") return null
try {
const raw = window.localStorage.getItem("qwen_oauth_token")
const raw = window.localStorage.getItem(getUserScopedKey("qwen_oauth_token"))
if (!raw) return null
return JSON.parse(raw)
} catch {
@@ -689,6 +690,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
}
try {
await ensureInstanceConfigLoaded(instanceId)
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
const response = await instance.client.app.agents()
const agentList = (response.data ?? []).map((agent) => ({
@@ -703,9 +705,16 @@ async function fetchAgents(instanceId: string): Promise<void> {
: undefined,
}))
const customAgents = getInstanceConfig(instanceId)?.customAgents ?? []
const customList = customAgents.map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: "custom",
}))
setAgents((prev) => {
const next = new Map(prev)
next.set(instanceId, agentList)
next.set(instanceId, [...agentList, ...customList])
return next
})
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,13 @@ import { getLogger } from "../lib/logger"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store"
import { sendMessage } from "./session-actions"
import { sendMessage, consumeTokenWarningSuppression, consumeCompactionSuppression, updateSessionModel } from "./session-actions"
import { showAlertDialog } from "./alerts"
import { sessions, setSessions, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
import { checkAndTriggerAutoCompact, getSessionCompactionState, setCompactionSuggestion } from "./session-compaction"
const log = getLogger("sse")
import { loadMessages } from "./session-api"
@@ -39,6 +40,7 @@ import {
} from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import type { InstanceMessageStore } from "./message-v2/instance-store"
import { getDefaultModel } from "./session-models"
interface TuiToastEvent {
type: "tui.toast.show"
@@ -232,6 +234,16 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
updateSessionInfo(instanceId, sessionId)
checkAndTriggerAutoCompact(instanceId, sessionId)
.then((shouldCompact) => {
if (!shouldCompact) return
if (getSessionCompactionState(instanceId, sessionId)) return
setCompactionSuggestion(instanceId, sessionId, "Context usage is high. Compact to continue.")
})
.catch((err) => {
log.error("Failed to check and trigger auto-compact", err)
})
}
}
@@ -389,6 +401,21 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
})
}
function isContextLengthError(error: any): boolean {
if (!error) return false
const errorMessage = error.data?.message || error.message || ""
return (
errorMessage.includes("maximum context length") ||
errorMessage.includes("context_length_exceeded") ||
errorMessage.includes("token count exceeds") ||
errorMessage.includes("token limit")
)
}
function isUnsupportedModelMessage(message: string): boolean {
return /model\s+.+\s+not supported/i.test(message)
}
function handleSessionError(instanceId: string, event: EventSessionError): void {
const error = event.properties?.error
log.error(`[SSE] Session error:`, error)
@@ -406,18 +433,73 @@ function handleSessionError(instanceId: string, event: EventSessionError): void
// Autonomous error recovery for SOLO
const solo = getSoloState(instanceId)
const sessionId = (event.properties as any)?.sessionID
if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) {
log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`)
incrementStep(instanceId)
sendMessage(instanceId, sessionId, `I encountered an error: "${message}". Please analyze the cause and provide a fix.`, [], solo.activeTaskId || undefined).catch((err) => {
log.error("[SOLO] Failed to send error recovery message", err)
})
return
}
// Check if this is a context length error
if (isContextLengthError(error)) {
if (sessionId && consumeCompactionSuppression(instanceId, sessionId)) {
showAlertDialog("Compaction failed because the model context limit was exceeded. Reduce context or switch to a larger context model, then try compact again.", {
title: "Compaction failed",
variant: "error",
})
return
}
if (sessionId && consumeTokenWarningSuppression(instanceId, sessionId)) {
showToastNotification({
title: "Context limit exceeded",
message: "Compaction is required before continuing.",
variant: "warning",
duration: 7000,
})
return
}
log.info("Context length error detected; suggesting compaction", { instanceId, sessionId })
if (sessionId) {
setCompactionSuggestion(instanceId, sessionId, "Context limit exceeded. Compact to continue.")
showToastNotification({
title: "Compaction required",
message: "Click Compact to continue this session.",
variant: "warning",
duration: 8000,
})
} else {
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
return
}
if (sessionId && isUnsupportedModelMessage(message)) {
showToastNotification({
title: "Model not supported",
message: "Selected model is not supported by this provider. Reverting to a default model.",
variant: "warning",
duration: 8000,
})
const sessionRecord = sessions().get(instanceId)?.get(sessionId)
getDefaultModel(instanceId, sessionRecord?.agent)
.then((fallback) => updateSessionModel(instanceId, sessionId, fallback))
.catch((err) => log.error("Failed to restore default model after unsupported model error", err))
return
}
// Default error handling
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {

View File

@@ -2,7 +2,7 @@ import type { Session, SessionStatus } from "../types/session"
import type { MessageInfo } from "../types/message"
import type { MessageRecord } from "./message-v2/types"
import { sessions } from "./sessions"
import { isSessionCompactionActive } from "./session-compaction"
import { getSessionCompactionState } from "./session-compaction"
import { messageStoreBus } from "./message-v2/bus"
function getSession(instanceId: string, sessionId: string): Session | null {
@@ -120,7 +120,7 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
const store = messageStoreBus.getOrCreate(instanceId)
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
if (getSessionCompactionState(instanceId, sessionId) || isSessionCompacting(session)) {
return "compacting"
}

View File

@@ -1,7 +1,8 @@
import { withSession } from "./session-state"
import { sessions, withSession } from "./session-state"
import { Task, TaskStatus } from "../types/session"
import { nanoid } from "nanoid"
import { forkSession } from "./session-api"
import { createSession } from "./session-api"
import { showToastNotification } from "../lib/notifications"
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
withSession(instanceId, sessionId, (session) => {
@@ -18,13 +19,32 @@ export async function addTask(
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
let taskSessionId: string | undefined
const parentSession = sessions().get(instanceId)?.get(sessionId)
const parentAgent = parentSession?.agent || ""
const parentModel = parentSession?.model
try {
console.log("[task-actions] forking session...");
const forked = await forkSession(instanceId, sessionId)
taskSessionId = forked.id
console.log("[task-actions] fork successful", { taskSessionId });
console.log("[task-actions] creating new task session...");
const created = await createSession(instanceId, parentAgent || undefined, { skipAutoCleanup: true })
taskSessionId = created.id
withSession(instanceId, taskSessionId, (taskSession) => {
taskSession.parentId = sessionId
if (parentAgent) {
taskSession.agent = parentAgent
}
if (parentModel?.providerId && parentModel?.modelId) {
taskSession.model = { ...parentModel }
}
})
console.log("[task-actions] task session created", { taskSessionId });
} catch (error) {
console.error("[task-actions] Failed to fork session for task", error)
console.error("[task-actions] Failed to create session for task", error)
showToastNotification({
title: "Task session unavailable",
message: "Continuing in the current session.",
variant: "warning",
duration: 5000,
})
taskSessionId = undefined
}
const newTask: Task = {
@@ -34,6 +54,7 @@ export async function addTask(
timestamp: Date.now(),
messageIds: [],
taskSessionId,
archived: false,
}
withSession(instanceId, sessionId, (session) => {
@@ -161,3 +182,15 @@ export function removeTask(instanceId: string, sessionId: string, taskId: string
}
})
}
export function archiveTask(instanceId: string, sessionId: string, taskId: string): void {
withSession(instanceId, sessionId, (session) => {
if (!session.tasks) return
session.tasks = session.tasks.map((task) =>
task.id === taskId ? { ...task, archived: true } : task,
)
if (session.activeTaskId === taskId) {
session.activeTaskId = undefined
}
})
}

View File

@@ -4,6 +4,7 @@ const [hasInstances, setHasInstances] = createSignal(false)
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
const [showFolderSelection, setShowFolderSelection] = createSignal(false)
const [showFolderSelectionOnStart, setShowFolderSelectionOnStart] = createSignal(true)
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
@@ -29,6 +30,8 @@ export {
setIsSelectingFolder,
showFolderSelection,
setShowFolderSelection,
showFolderSelectionOnStart,
setShowFolderSelectionOnStart,
instanceTabOrder,
setInstanceTabOrder,
sessionTabOrder,

View File

@@ -0,0 +1,89 @@
import { createSignal } from "solid-js"
import { getLogger } from "../lib/logger"
export interface UserAccount {
id: string
name: string
isGuest?: boolean
}
const log = getLogger("users")
const [users, setUsers] = createSignal<UserAccount[]>([])
const [activeUser, setActiveUserSignal] = createSignal<UserAccount | null>(null)
const [loadingUsers, setLoadingUsers] = createSignal(false)
function getElectronApi() {
return typeof window !== "undefined" ? window.electronAPI : undefined
}
async function refreshUsers(): Promise<void> {
const api = getElectronApi()
if (!api?.listUsers) return
setLoadingUsers(true)
try {
const list = await api.listUsers()
setUsers(list ?? [])
const active = api.getActiveUser ? await api.getActiveUser() : null
setActiveUserSignal(active ?? null)
} catch (error) {
log.warn("Failed to load users", error)
} finally {
setLoadingUsers(false)
}
}
async function createUser(name: string, password: string): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.createUser) return null
const user = await api.createUser({ name, password })
await refreshUsers()
return user ?? null
}
async function updateUser(id: string, updates: { name?: string; password?: string }): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.updateUser) return null
const user = await api.updateUser({ id, ...updates })
await refreshUsers()
return user ?? null
}
async function deleteUser(id: string): Promise<void> {
const api = getElectronApi()
if (!api?.deleteUser) return
await api.deleteUser({ id })
await refreshUsers()
}
async function loginUser(id: string, password?: string): Promise<boolean> {
const api = getElectronApi()
if (!api?.loginUser) return false
const result = await api.loginUser({ id, password })
if (result?.success) {
setActiveUserSignal(result.user ?? null)
await refreshUsers()
return true
}
return false
}
async function createGuest(): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.createGuest) return null
const user = await api.createGuest()
await refreshUsers()
return user ?? null
}
export {
users,
activeUser,
loadingUsers,
refreshUsers,
createUser,
updateUser,
deleteUser,
loginUser,
createGuest,
}

View File

@@ -0,0 +1,95 @@
.mcp-manager {
@apply flex flex-col gap-3;
}
.mcp-manager-header {
@apply flex items-center justify-between;
}
.mcp-manager-actions {
@apply flex items-center gap-2;
}
.mcp-action-button {
@apply flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white;
}
.mcp-link-button {
@apply px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 hover:text-white;
}
.mcp-icon-button {
@apply p-1 rounded-md border border-white/10 text-zinc-400 hover:text-white hover:border-white/20;
}
.mcp-menu {
@apply absolute right-0 mt-2 w-48 rounded-md border border-white/10 bg-zinc-950 shadow-xl z-50 overflow-hidden;
}
.mcp-menu-item {
@apply w-full px-3 py-2 text-left text-[11px] text-zinc-300 hover:text-white hover:bg-white/5 flex items-center justify-between gap-2;
}
.mcp-server-list {
@apply flex flex-col gap-2;
}
.mcp-server-card {
@apply px-2 py-2 rounded border bg-white/5 border-white/10;
}
.mcp-server-row {
@apply flex items-center justify-between gap-2;
}
.mcp-status-chip {
@apply text-[10px] px-2 py-0.5 rounded-full border border-emerald-500/40 text-emerald-300 bg-emerald-500/10 uppercase tracking-wide;
}
.mcp-status-error {
@apply text-[10px] px-2 py-0.5 rounded-full border border-rose-500/40 text-rose-300 bg-rose-500/10 uppercase tracking-wide;
}
.mcp-market-search {
@apply flex items-center gap-2 border border-white/10 rounded-lg px-3 py-2 bg-white/5;
}
.mcp-market-input {
@apply w-full bg-transparent text-xs text-zinc-200 outline-none;
}
.mcp-market-list {
@apply flex flex-col gap-2 max-h-[60vh] overflow-y-auto pr-1;
}
.mcp-market-card {
@apply flex items-start justify-between gap-4 border border-white/10 rounded-lg bg-white/5 p-3;
}
.mcp-market-card-title {
@apply text-xs font-semibold text-zinc-100 flex items-center gap-2;
}
.mcp-market-card-desc {
@apply text-[11px] text-zinc-500 mt-1;
}
.mcp-market-tags {
@apply flex flex-wrap gap-1 mt-2;
}
.mcp-market-tag {
@apply text-[9px] uppercase tracking-wide px-2 py-0.5 rounded-full border border-white/10 text-zinc-400;
}
.mcp-market-source {
@apply text-[9px] uppercase tracking-wide px-2 py-0.5 rounded-full border border-white/10 text-zinc-500;
}
.mcp-market-card-actions {
@apply flex items-center gap-2;
}
.mcp-market-install {
@apply flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 hover:text-white;
}

View File

@@ -6,3 +6,4 @@
@import "./components/env-vars.css";
@import "./components/directory-browser.css";
@import "./components/remote-access.css";
@import "./components/mcp-manager.css";

View File

@@ -53,6 +53,20 @@
@apply text-[11px] text-[var(--text-muted)];
}
.message-model-badge {
@apply inline-flex items-center px-2 py-1 rounded-full border;
border-color: var(--border-base);
background-color: var(--surface-secondary);
color: var(--text-muted);
transition: all 0.2s ease;
}
.message-model-badge:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
background-color: var(--surface-hover);
}
.assistant-message {
/* gap: 0.25rem; */
padding: 0.6rem 0.65rem;
@@ -121,6 +135,13 @@
border-color: var(--status-error);
}
.compact-button {
@apply ml-2 px-2 py-1 rounded bg-emerald-500/20 border border-emerald-500/40 text-emerald-400 text-xs font-semibold hover:bg-emerald-500/30 transition-all;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.message-generating {
@apply text-sm italic py-2;
color: var(--text-muted);
@@ -146,6 +167,58 @@
animation: pulse 1.5s ease-in-out infinite;
}
.message-streaming-indicator {
@apply inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-purple-500/50 bg-purple-500/10 mb-2;
}
.streaming-status {
@apply inline-flex items-center gap-2 text-sm;
}
.streaming-pulse {
@apply inline-block w-2 h-2 rounded-full bg-purple-500;
animation: streaming-pulse 1s ease-in-out infinite;
}
@keyframes streaming-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 8px 4px rgba(168, 85, 247, 0.6);
}
50% {
opacity: 0.6;
transform: scale(0.8);
box-shadow: 0 0 12px 6px rgba(168, 85, 247, 0.8);
}
}
.streaming-text {
@apply text-purple-400 font-semibold tracking-wide;
animation: streaming-text-pulse 1.5s ease-in-out infinite;
}
@keyframes streaming-text-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.streaming-tokens {
@apply inline-flex items-center gap-1 px-2 py-1 rounded-full bg-purple-500/20 border border-purple-500/30;
}
.streaming-token-count {
@apply text-purple-300 font-mono font-bold;
}
.streaming-token-label {
@apply text-purple-400 text-xs font-medium;
}
.message-text {
font-size: var(--font-size-base);
line-height: var(--line-height-normal);

View File

@@ -1,7 +1,27 @@
.message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
@apply flex-1 min-h-0 overflow-y-auto overflow-x-hidden flex flex-col gap-0.5;
background-color: var(--surface-base);
color: inherit;
scrollbar-width: thin;
scrollbar-color: var(--border-base) transparent;
scrollbar-gutter: stable;
}
.message-stream::-webkit-scrollbar {
width: 8px;
}
.message-stream::-webkit-scrollbar-track {
background: transparent;
}
.message-stream::-webkit-scrollbar-thumb {
background: var(--border-base);
border-radius: 4px;
}
.message-stream::-webkit-scrollbar-thumb:hover {
background: var(--border-base-hover);
}
.message-stream-block {

View File

@@ -1,5 +1,54 @@
.message-stream-container {
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
width: 100%;
}
.compaction-banner {
@apply sticky top-0 z-10 flex items-center gap-2 px-4 py-2 text-xs font-medium;
background-color: var(--surface-secondary);
color: var(--text-primary);
border-bottom: 1px solid var(--border-base);
}
.compaction-banner-spinner {
@apply w-4 h-4 border-2 border-t-transparent rounded-full;
border-color: var(--border-base);
border-top-color: var(--accent-primary);
animation: spin 1s linear infinite;
}
.compaction-suggestion {
@apply sticky top-0 z-10 flex items-center justify-between gap-3 px-4 py-2 text-xs font-medium;
background-color: rgba(22, 163, 74, 0.12);
color: var(--text-primary);
border-bottom: 1px solid rgba(22, 163, 74, 0.35);
}
.compaction-suggestion-text {
@apply flex flex-col gap-0.5;
}
.compaction-suggestion-label {
@apply uppercase tracking-wide text-[10px] font-semibold;
color: rgba(74, 222, 128, 0.9);
}
.compaction-suggestion-message {
color: var(--text-secondary);
}
.compaction-suggestion-action {
@apply inline-flex items-center justify-center px-3 py-1.5 rounded-lg text-[10px] font-semibold uppercase tracking-wide;
border: 1px solid rgba(34, 197, 94, 0.5);
background-color: rgba(34, 197, 94, 0.2);
color: #4ade80;
transition: transform 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.compaction-suggestion-action:hover {
background-color: rgba(34, 197, 94, 0.3);
color: #86efac;
transform: translateY(-1px);
}
.connection-status {

View File

@@ -38,12 +38,14 @@
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.message-stream-shell .message-stream {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.message-timeline-sidebar {

View File

@@ -1,6 +1,6 @@
/* Prompt input & attachment styles */
.prompt-input-container {
@apply flex flex-col relative mx-auto w-full max-w-4xl;
@apply flex flex-col relative mx-auto w-full max-w-4xl flex-shrink-0;
padding: 1rem 1.5rem 1.5rem;
background-color: transparent;
}
@@ -32,6 +32,20 @@
@apply absolute right-2 bottom-2 flex items-center space-x-1.5 z-20;
}
.thinking-indicator {
@apply flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-semibold uppercase tracking-wide;
border: 1px solid rgba(129, 140, 248, 0.4);
background-color: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
}
.thinking-spinner {
@apply w-3 h-3 border-2 border-t-transparent rounded-full;
border-color: rgba(129, 140, 248, 0.4);
border-top-color: #a5b4fc;
animation: spin 0.9s linear infinite;
}
.send-button, .stop-button {
@apply w-8 h-8 rounded-xl border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0 shadow-lg;
}

View File

@@ -214,7 +214,7 @@
}
.tool-call-diff-toolbar {
@apply flex items-center justify-between gap-3 px-3 py-2;
@apply flex flex-wrap items-center gap-3 px-3 py-2;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
}
@@ -227,7 +227,7 @@
}
.tool-call-diff-toggle {
@apply inline-flex items-center gap-1;
@apply inline-flex flex-wrap items-center gap-1;
}
.tool-call-diff-mode-button {

View File

@@ -0,0 +1,191 @@
/* Responsive Design for Electron Interface */
/* Base container adjustments for small screens */
@media (max-width: 640px) {
.session-shell-panels {
overflow: hidden;
}
.session-toolbar {
padding: 0.25rem 0.5rem;
min-height: 36px;
}
.session-toolbar button,
.session-toolbar .icon-button {
padding: 0.25rem;
}
.session-toolbar .hidden.md\:flex {
display: none !important;
}
.content-area {
min-width: 0;
}
}
/* Tablet adjustments */
@media (min-width: 641px) and (max-width: 1024px) {
.session-toolbar {
padding: 0.5rem 0.75rem;
}
.content-area {
min-width: 0;
}
}
/* Desktop adjustments */
@media (min-width: 1025px) {
.content-area {
min-width: 0;
}
}
/* Ensure all scrollable containers handle overflow properly */
@media (max-width: 768px) {
.flex-1.min-h-0 {
min-height: 0;
flex: 1 1 0%;
}
.overflow-y-auto {
overflow-y: auto;
}
.min-h-0 {
min-height: 0;
}
}
/* Fix drawer widths on mobile */
@media (max-width: 768px) {
.session-sidebar-container,
.session-right-panel {
max-width: 100vw !important;
}
}
/* Chat panel adjustments for small screens */
@media (max-width: 640px) {
.flex.flex-col.relative.border-l {
min-width: 280px !important;
}
}
/* Terminal adjustments */
@media (max-width: 768px) {
.terminal-panel {
min-height: 100px !important;
max-height: 40vh !important;
}
}
/* Prevent horizontal scroll on root levels only */
html,
body,
#root {
overflow-x: hidden;
width: 100%;
max-width: 100vw;
}
/* Ensure proper flex sizing throughout the app */
.flex-1 {
flex: 1 1 0%;
}
.min-h-0 {
min-height: 0;
}
/* Ensure scrollable containers work correctly */
.overflow-y-auto {
overflow-y: auto;
overflow-x: hidden;
}
/* Ensure viewport meta tag behavior */
@viewport {
width: device-width;
zoom: 1.0;
}
/* Touch-friendly adjustments for mobile */
@media (hover: none) and (pointer: coarse) {
.session-resize-handle {
width: 16px;
}
.message-scroll-button {
width: 3rem;
height: 3rem;
}
}
/* High DPI adjustments */
@media (-webkit-min-device-pixel-ratio: 2),
(min-resolution: 192dpi) {
/* Enhance text rendering on high-dpi screens */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Ensure the main app container is fully adaptive */
.app-container,
[data-app-container="true"],
#root>div {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
overflow: hidden;
}
/* Fix message navigation sidebar from being cut off */
.message-navigation-sidebar,
[class*="message-nav"],
.shrink-0.overflow-y-auto.border-l {
min-width: 28px;
max-width: 48px;
flex-shrink: 0;
overflow-x: hidden;
}
/* Ensure panels don't overflow their containers */
.panel,
[role="main"],
main {
max-width: 100%;
overflow-x: hidden;
}
/* Fix right-side badges and avatars */
.message-avatar,
.message-role-badge,
[class*="shrink-0"][class*="border-l"] {
min-width: min-content;
overflow: visible;
}
/* Ensure proper Electron window behavior */
@media screen {
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
}
/* Text rendering optimization */
body {
-webkit-font-smoothing: subpixel-antialiased;
}

View File

@@ -236,23 +236,6 @@
--session-tab-active-bg: var(--surface-muted);
--session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted);
--session-tab-hover-bg: #3f3f46;
--button-primary-bg: #3f3f46;
--button-primary-hover-bg: #52525b;
--button-primary-text: #f5f6f8;
--tab-active-bg: #3f3f46;
--tab-active-hover-bg: #52525b;
--tab-active-text: #f5f6f8;
--tab-inactive-bg: #2f2f36;
--tab-inactive-hover-bg: #3d3d45;
--tab-inactive-text: #d4d4d8;
--new-tab-bg: #3f3f46;
--new-tab-hover-bg: #52525b;
--new-tab-text: #f5f6f8;
--session-tab-active-bg: var(--surface-muted);
--session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted);
--session-tab-hover-bg: #3f3f46;
/* Layout & spacing tokens */

View File

@@ -48,7 +48,22 @@
}
.icon-danger-hover:hover {
color: var(--status-error);
color: #ef4444;
}
/* Tooltip styles */
.tooltip-content {
background-color: var(--surface-base);
border: 1px solid var(--border-base);
color: var(--text-primary);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
max-width: 200px;
z-index: 1000;
}
.tooltip-content .font-bold {
color: var(--accent-primary);
}
.icon-accent-hover:hover {

View File

@@ -26,6 +26,13 @@ declare global {
onCliError?: (callback: (data: unknown) => void) => () => void
getCliStatus?: () => Promise<unknown>
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
listUsers?: () => Promise<Array<{ id: string; name: string; isGuest?: boolean }>>
getActiveUser?: () => Promise<{ id: string; name: string; isGuest?: boolean } | null>
createUser?: (payload: { name: string; password: string }) => Promise<{ id: string; name: string; isGuest?: boolean }>
updateUser?: (payload: { id: string; name?: string; password?: string }) => Promise<{ id: string; name: string; isGuest?: boolean }>
deleteUser?: (payload: { id: string }) => Promise<{ success: boolean }>
createGuest?: () => Promise<{ id: string; name: string; isGuest?: boolean }>
loginUser?: (payload: { id: string; password?: string }) => Promise<{ success: boolean; user?: { id: string; name: string; isGuest?: boolean } }>
}
interface TauriDialogModule {

View File

@@ -4,6 +4,7 @@ import { resolve } from "path"
export default defineConfig({
root: "./src/renderer",
publicDir: resolve(__dirname, "./public"),
plugins: [solid()],
css: {
postcss: "./postcss.config.js",
@@ -20,10 +21,11 @@ export default defineConfig({
noExternal: ["lucide-solid"],
},
server: {
port: 3000,
port: Number(process.env.VITE_PORT ?? 3000),
},
build: {
outDir: "dist",
outDir: resolve(__dirname, "dist"),
chunkSizeWarningLimit: 1000,
rollupOptions: {
input: {
main: resolve(__dirname, "./src/renderer/index.html"),

View File

@@ -0,0 +1,41 @@
// vite.config.ts
import { defineConfig } from "file:///E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/vite/dist/node/index.js";
import solid from "file:///E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { resolve } from "path";
var __vite_injected_original_dirname = "E:\\TRAE Playground\\NeuralNomadsAi\\NomadArch\\packages\\ui";
var vite_config_default = defineConfig({
root: "./src/renderer",
publicDir: resolve(__vite_injected_original_dirname, "./public"),
plugins: [solid()],
css: {
postcss: "./postcss.config.js"
},
resolve: {
alias: {
"@": resolve(__vite_injected_original_dirname, "./src")
}
},
optimizeDeps: {
exclude: ["lucide-solid"]
},
ssr: {
noExternal: ["lucide-solid"]
},
server: {
port: Number(process.env.VITE_PORT ?? 3e3)
},
build: {
outDir: resolve(__vite_injected_original_dirname, "dist"),
chunkSizeWarningLimit: 1e3,
rollupOptions: {
input: {
main: resolve(__vite_injected_original_dirname, "./src/renderer/index.html"),
loading: resolve(__vite_injected_original_dirname, "./src/renderer/loading.html")
}
}
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJFOlxcXFxUUkFFIFBsYXlncm91bmRcXFxcTmV1cmFsTm9tYWRzQWlcXFxcTm9tYWRBcmNoXFxcXHBhY2thZ2VzXFxcXHVpXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJFOlxcXFxUUkFFIFBsYXlncm91bmRcXFxcTmV1cmFsTm9tYWRzQWlcXFxcTm9tYWRBcmNoXFxcXHBhY2thZ2VzXFxcXHVpXFxcXHZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9FOi9UUkFFJTIwUGxheWdyb3VuZC9OZXVyYWxOb21hZHNBaS9Ob21hZEFyY2gvcGFja2FnZXMvdWkvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiXHJcbmltcG9ydCBzb2xpZCBmcm9tIFwidml0ZS1wbHVnaW4tc29saWRcIlxyXG5pbXBvcnQgeyByZXNvbHZlIH0gZnJvbSBcInBhdGhcIlxyXG5cclxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcclxuICByb290OiBcIi4vc3JjL3JlbmRlcmVyXCIsXHJcbiAgcHVibGljRGlyOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3B1YmxpY1wiKSxcclxuICBwbHVnaW5zOiBbc29saWQoKV0sXHJcbiAgY3NzOiB7XHJcbiAgICBwb3N0Y3NzOiBcIi4vcG9zdGNzcy5jb25maWcuanNcIixcclxuICB9LFxyXG4gIHJlc29sdmU6IHtcclxuICAgIGFsaWFzOiB7XHJcbiAgICAgIFwiQFwiOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyY1wiKSxcclxuICAgIH0sXHJcbiAgfSxcclxuICBvcHRpbWl6ZURlcHM6IHtcclxuICAgIGV4Y2x1ZGU6IFtcImx1Y2lkZS1zb2xpZFwiXSxcclxuICB9LFxyXG4gIHNzcjoge1xyXG4gICAgbm9FeHRlcm5hbDogW1wibHVjaWRlLXNvbGlkXCJdLFxyXG4gIH0sXHJcbiAgc2VydmVyOiB7XHJcbiAgICBwb3J0OiBOdW1iZXIocHJvY2Vzcy5lbnYuVklURV9QT1JUID8/IDMwMDApLFxyXG4gIH0sXHJcbiAgYnVpbGQ6IHtcclxuICAgIG91dERpcjogcmVzb2x2ZShfX2Rpcm5hbWUsIFwiZGlzdFwiKSxcclxuICAgIGNodW5rU2l6ZVdhcm5pbmdMaW1pdDogMTAwMCxcclxuICAgIHJvbGx1cE9wdGlvbnM6IHtcclxuICAgICAgaW5wdXQ6IHtcclxuICAgICAgICBtYWluOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyYy9yZW5kZXJlci9pbmRleC5odG1sXCIpLFxyXG4gICAgICAgIGxvYWRpbmc6IHJlc29sdmUoX19kaXJuYW1lLCBcIi4vc3JjL3JlbmRlcmVyL2xvYWRpbmcuaHRtbFwiKSxcclxuICAgICAgfSxcclxuICAgIH0sXHJcbiAgfSxcclxufSlcclxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFxVyxTQUFTLG9CQUFvQjtBQUNsWSxPQUFPLFdBQVc7QUFDbEIsU0FBUyxlQUFlO0FBRnhCLElBQU0sbUNBQW1DO0FBSXpDLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLE1BQU07QUFBQSxFQUNOLFdBQVcsUUFBUSxrQ0FBVyxVQUFVO0FBQUEsRUFDeEMsU0FBUyxDQUFDLE1BQU0sQ0FBQztBQUFBLEVBQ2pCLEtBQUs7QUFBQSxJQUNILFNBQVM7QUFBQSxFQUNYO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUCxPQUFPO0FBQUEsTUFDTCxLQUFLLFFBQVEsa0NBQVcsT0FBTztBQUFBLElBQ2pDO0FBQUEsRUFDRjtBQUFBLEVBQ0EsY0FBYztBQUFBLElBQ1osU0FBUyxDQUFDLGNBQWM7QUFBQSxFQUMxQjtBQUFBLEVBQ0EsS0FBSztBQUFBLElBQ0gsWUFBWSxDQUFDLGNBQWM7QUFBQSxFQUM3QjtBQUFBLEVBQ0EsUUFBUTtBQUFBLElBQ04sTUFBTSxPQUFPLFFBQVEsSUFBSSxhQUFhLEdBQUk7QUFBQSxFQUM1QztBQUFBLEVBQ0EsT0FBTztBQUFBLElBQ0wsUUFBUSxRQUFRLGtDQUFXLE1BQU07QUFBQSxJQUNqQyx1QkFBdUI7QUFBQSxJQUN2QixlQUFlO0FBQUEsTUFDYixPQUFPO0FBQUEsUUFDTCxNQUFNLFFBQVEsa0NBQVcsMkJBQTJCO0FBQUEsUUFDcEQsU0FBUyxRQUFRLGtDQUFXLDZCQUE2QjtBQUFBLE1BQzNEO0FBQUEsSUFDRjtBQUFBLEVBQ0Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=

54
scripts/check-port.js Normal file
View File

@@ -0,0 +1,54 @@
const net = require('net')
const DEFAULT_SERVER_PORT = 3001
const DEFAULT_UI_PORT = 3000
function isPortAvailable(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const server = net.createServer()
server.once('error', () => {
resolve(false)
})
server.once('listening', () => {
server.close()
resolve(true)
})
server.listen(port, host)
})
}
function findAvailablePort(startPort, maxAttempts = 50, host = '127.0.0.1') {
for (let port = startPort; port < startPort + maxAttempts; port++) {
try {
const available = await isPortAvailable(port, host)
if (available) {
return port
}
} catch (error) {
console.error(`Error checking port ${port}:`, error.message)
}
}
return 0
}
async function main() {
const args = process.argv.slice(2)
const mode = args[0]
if (mode === 'server') {
const port = await findAvailablePort(DEFAULT_SERVER_PORT)
console.log(port)
} else if (mode === 'ui') {
const port = await findAvailablePort(DEFAULT_UI_PORT)
console.log(port)
} else {
const serverPort = await findAvailablePort(DEFAULT_SERVER_PORT)
const uiPort = await findAvailablePort(DEFAULT_UI_PORT)
console.log(`${serverPort},${uiPort}`)
}
}
main().catch(error => {
console.error('Error:', error.message)
process.exit(1)
})