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 #!/bin/bash
echo "" # NomadArch Installer for Linux
echo " ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗" # Version: 0.4.0
echo " ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║"
echo " ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║"
echo " ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║"
echo " ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║"
echo " ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝"
echo ""
echo " INSTALLER - Enhanced with Auto-Dependency Resolution"
echo " ═════════════════════════════════════════════════════════════════════════════"
echo ""
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 ERRORS=0
WARNINGS=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 "" 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 . /etc/os-release
DISTRO=$ID echo -e "${GREEN}[INFO]${NC} Distribution: ${PRETTY_NAME:-unknown}"
DISTRO_VERSION=$VERSION_ID
echo "[OK] Detected: $PRETTY_NAME"
else
echo "[WARN] Could not detect specific distribution"
DISTRO="unknown"
WARNINGS=$((WARNINGS + 1))
fi fi
echo "" echo ""
echo "[STEP 2/7] Checking System Requirements..." echo "[STEP 2/9] Checking write permissions"
echo "" mkdir -p "$BIN_DIR"
if ! touch "$SCRIPT_DIR/.install-write-test" 2>/dev/null; then
if ! command -v node &> /dev/null; then echo -e "${YELLOW}[WARN]${NC} No write access to $SCRIPT_DIR"
echo "[ERROR] Node.js not found!" TARGET_DIR="$HOME/.nomadarch-install"
echo "" BIN_DIR="$TARGET_DIR/bin"
echo "NomadArch requires Node.js to run." LOG_FILE="$TARGET_DIR/install.log"
echo "" mkdir -p "$BIN_DIR"
echo "Install using your package manager:" if ! touch "$TARGET_DIR/.install-write-test" 2>/dev/null; then
if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then echo -e "${RED}[ERROR]${NC} Cannot write to $TARGET_DIR"
echo " sudo apt update && sudo apt install -y nodejs npm" log "ERROR: Write permission denied to fallback"
elif [ "$DISTRO" = "fedora" ]; then exit 1
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"
fi fi
echo "" rm -f "$TARGET_DIR/.install-write-test"
echo "Or install Node.js using NVM (Node Version Manager):" NEEDS_FALLBACK=1
echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash" echo -e "${GREEN}[OK]${NC} Using fallback: $TARGET_DIR"
echo " source ~/.bashrc" else
echo " nvm install 20" rm -f "$SCRIPT_DIR/.install-write-test"
echo "" echo -e "${GREEN}[OK]${NC} Write access OK"
fi
log "Install target: $TARGET_DIR"
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 exit 1
fi fi
NODE_VERSION=$(node --version) NODE_VERSION=$(node --version)
echo "[OK] Node.js detected: $NODE_VERSION" NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d'v' -f2 | cut -d'.' -f1)
echo -e "${GREEN}[OK]${NC} Node.js: $NODE_VERSION"
NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1 | sed 's/v//') if [[ $NODE_MAJOR -lt 18 ]]; then
if [ "$NODE_MAJOR" -lt 18 ]; then echo -e "${YELLOW}[WARN]${NC} Node.js 18+ is recommended"
echo "[WARN] Node.js version is too old (found v$NODE_VERSION, required 18+)" ((WARNINGS++))
echo "[INFO] Please update Node.js"
WARNINGS=$((WARNINGS + 1))
fi fi
if ! command -v npm &> /dev/null; then if ! command -v npm >/dev/null 2>&1; then
echo "[ERROR] npm not found! This should come with Node.js." echo -e "${RED}[ERROR]${NC} npm is not available"
echo "Please reinstall Node.js" log "ERROR: npm missing after install"
ERRORS=$((ERRORS + 1)) exit 1
fi fi
NPM_VERSION=$(npm --version) NPM_VERSION=$(npm --version)
echo "[OK] npm detected: $NPM_VERSION" echo -e "${GREEN}[OK]${NC} npm: $NPM_VERSION"
echo "" if command -v git >/dev/null 2>&1; then
echo "[STEP 3/7] Checking OpenCode CLI..." echo -e "${GREEN}[OK]${NC} Git: $(git --version)"
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 else
OPENCODE_DONE=false echo -e "${YELLOW}[WARN]${NC} Git not found (optional)"
fi ((WARNINGS++))
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
fi fi
echo "" echo ""
echo "[STEP 4/7] Installing NomadArch Dependencies..." echo "[STEP 4/9] Installing npm dependencies"
echo "" cd "$SCRIPT_DIR"
log "Running npm install"
if [ -d "node_modules" ]; then if ! npm install; then
echo "[INFO] node_modules found. Skipping dependency installation." echo -e "${RED}[ERROR]${NC} npm install failed"
echo "[INFO] To force reinstall, delete node_modules and run again." log "ERROR: npm install failed"
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 ""
exit 1 exit 1
fi fi
echo "" echo -e "${GREEN}[OK]${NC} Dependencies installed"
echo "════════════════════════════════════════════════════════════════════════════"
echo "[SUCCESS] Installation Complete!"
echo ""
if [ $WARNINGS -gt 0 ]; then echo ""
echo "[WARN] There were $WARNINGS warning(s) during installation." echo "[STEP 5/9] Fetching OpenCode binary"
echo "Review warnings above. Most warnings are non-critical." mkdir -p "$BIN_DIR"
echo "" 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 fi
echo "You can now run NomadArch using:"
echo " ./Launch-Unix.sh"
echo "" echo ""
echo "For help and documentation, see: README.md" echo "[STEP 6/9] Building UI assets"
echo "════════════════════════════════════════════════════════════════════════════" 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 ""
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 #!/bin/bash
echo "" # NomadArch Installer for macOS
echo " ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗" # Version: 0.4.0
echo " ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║"
echo " ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║"
echo " ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║"
echo " ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║"
echo " ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝"
echo ""
echo " INSTALLER - macOS Enhanced with Auto-Dependency Resolution"
echo " ═══════════════════════════════════════════════════════════════════════════"
echo ""
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 ERRORS=0
WARNINGS=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 "" echo ""
if [ -f /System/Library/CoreServices/SystemVersion.plist ]; then log "Installer started"
MAC_VERSION=$(defaults read /System/Library/CoreServices/SystemVersion.plist ProductVersion)
MAC_MAJOR=$(echo $MAC_VERSION | cut -d. -f1)
echo "[OK] macOS detected: $MAC_VERSION"
if [ "$MAC_MAJOR" -lt 11 ]; then echo "[STEP 1/9] OS and Architecture Detection"
echo "[WARN] NomadArch requires macOS 11+ (Big Sur or later)" OS_TYPE=$(uname -s)
echo "[INFO] Your version is $MAC_VERSION" ARCH_TYPE=$(uname -m)
echo "[INFO] Please upgrade macOS to continue" 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 exit 1
fi fi
rm -f "$TARGET_DIR/.install-write-test"
NEEDS_FALLBACK=1
echo -e "${GREEN}[OK]${NC} Using fallback: $TARGET_DIR"
else else
echo "[WARN] Could not detect macOS version" rm -f "$SCRIPT_DIR/.install-write-test"
WARNINGS=$((WARNINGS + 1)) echo -e "${GREEN}[OK]${NC} Write access OK"
fi fi
ARCH=$(uname -m) log "Install target: $TARGET_DIR"
if [ "$ARCH" = "arm64" ]; then
echo "[OK] Apple Silicon detected (M1/M2/M3 chip)" echo ""
elif [ "$ARCH" = "x86_64" ]; then echo "[STEP 3/9] Ensuring system dependencies"
echo "[OK] Intel Mac detected"
else if ! command -v curl >/dev/null 2>&1; then
echo "[WARN] Unknown architecture: $ARCH" echo -e "${RED}[ERROR]${NC} curl is required but not available"
WARNINGS=$((WARNINGS + 1)) exit 1
fi fi
echo "" if ! command -v brew >/dev/null 2>&1; then
echo "[STEP 2/7] Checking System Requirements..." echo -e "${YELLOW}[INFO]${NC} Homebrew not found. Installing..."
echo "" /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
if ! command -v node &> /dev/null; then MISSING_PKGS=()
echo "[ERROR] Node.js not found!" command -v git >/dev/null 2>&1 || MISSING_PKGS+=("git")
echo "" command -v node >/dev/null 2>&1 || MISSING_PKGS+=("node")
echo "NomadArch requires Node.js to run."
echo "" if [[ ${#MISSING_PKGS[@]} -gt 0 ]]; then
echo "Install Node.js using one of these methods:" echo -e "${BLUE}[INFO]${NC} Installing: ${MISSING_PKGS[*]}"
echo "" brew install "${MISSING_PKGS[@]}"
echo " 1. Homebrew (recommended):" fi
echo " brew install node"
echo "" if ! command -v node >/dev/null 2>&1; then
echo " 2. Download from official site:" echo -e "${RED}[ERROR]${NC} Node.js install failed"
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 ""
exit 1 exit 1
fi fi
NODE_VERSION=$(node --version) NODE_VERSION=$(node --version)
echo "[OK] Node.js detected: $NODE_VERSION" NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d'v' -f2 | cut -d'.' -f1)
echo -e "${GREEN}[OK]${NC} Node.js: $NODE_VERSION"
NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1 | sed 's/v//') if [[ $NODE_MAJOR -lt 18 ]]; then
if [ "$NODE_MAJOR" -lt 18 ]; then echo -e "${YELLOW}[WARN]${NC} Node.js 18+ is recommended"
echo "[WARN] Node.js version is too old (found v$NODE_VERSION, required 18+)" ((WARNINGS++))
echo "[INFO] Please update Node.js: brew upgrade node"
WARNINGS=$((WARNINGS + 1))
fi fi
if ! command -v npm &> /dev/null; then if ! command -v npm >/dev/null 2>&1; then
echo "[ERROR] npm not found! This should come with Node.js." echo -e "${RED}[ERROR]${NC} npm is not available"
echo "Please reinstall Node.js" exit 1
ERRORS=$((ERRORS + 1))
fi fi
NPM_VERSION=$(npm --version) 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 git >/dev/null 2>&1; then
if ! command -v xcode-select &> /dev/null; then echo -e "${GREEN}[OK]${NC} Git: $(git --version)"
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))
else else
XCODE_PATH=$(xcode-select -p) echo -e "${YELLOW}[WARN]${NC} Git not found (optional)"
echo "[OK] Xcode Command Line Tools detected: $XCODE_PATH" ((WARNINGS++))
fi fi
echo "" echo ""
echo "[STEP 3/7] Checking OpenCode CLI..." echo "[STEP 4/9] Installing npm dependencies"
echo "" cd "$SCRIPT_DIR"
log "Running npm install"
if command -v opencode &> /dev/null; then if ! npm install; then
echo "[OK] OpenCode is already installed globally" echo -e "${RED}[ERROR]${NC} npm install failed"
OPENCODE_DONE=true log "ERROR: npm install failed"
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 ""
exit 1 exit 1
fi fi
echo "" echo -e "${GREEN}[OK]${NC} Dependencies installed"
echo "════════════════════════════════════════════════════════════════════════════"
echo "[SUCCESS] Installation Complete!"
echo ""
if [ $WARNINGS -gt 0 ]; then echo ""
echo "[WARN] There were $WARNINGS warning(s) during installation." echo "[STEP 5/9] Fetching OpenCode binary"
echo "Review warnings above. Most warnings are non-critical." mkdir -p "$BIN_DIR"
echo "" 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 fi
echo "You can now run NomadArch using:"
echo " ./Launch-Unix.sh"
echo "" echo ""
echo "For help and documentation, see: README.md" echo "[STEP 6/9] Building UI assets"
echo "════════════════════════════════════════════════════════════════════════════" 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 ""
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 @echo off
title NomadArch Installer
color 0A
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
title NomadArch Installer
echo. echo.
echo ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗ echo NomadArch Installer (Windows)
echo ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║ echo Version: 0.4.0
echo ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║
echo ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║
echo ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║
echo ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
echo.
echo INSTALLER - Enhanced with Auto-Dependency Resolution
echo ═══════════════════════════════════════════════════════════════════════════════
echo. 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 ERRORS=0
set WARNINGS=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.
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 echo. > "%SCRIPT_DIR%\test-write.tmp" 2>nul
net session >nul 2>&1
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [WARN] Not running as Administrator. Some operations may fail. echo [WARN] Cannot write to current directory: %SCRIPT_DIR%
set /a WARNINGS+=1 set TARGET_DIR=%USERPROFILE%\NomadArch-Install
echo. 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 where node >nul 2>&1
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [ERROR] Node.js not found! echo [INFO] Node.js not found. Attempting to install...
echo. if %WINGET_AVAILABLE% equ 1 (
echo NomadArch requires Node.js to run. winget install -e --id OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements
echo. ) else if %CHOCO_AVAILABLE% equ 1 (
echo Download from: https://nodejs.org/ choco install nodejs-lts -y
echo Recommended: Node.js 18.x LTS or 20.x LTS ) else (
echo. echo [ERROR] No supported package manager found (winget/choco).
echo Opening download page... echo Please install Node.js LTS from https://nodejs.org/
start "" "https://nodejs.org/" set /a ERRORS+=1
echo. goto :SUMMARY
echo Please install Node.js and run this installer again. )
echo.
pause
exit /b 1
) )
:: Display Node.js version where node >nul 2>&1
for /f "tokens=*" %%i in ('node --version') do set NODE_VERSION=%%i if %ERRORLEVEL% neq 0 (
echo [OK] Node.js found: %NODE_VERSION% 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 where npm >nul 2>&1
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [ERROR] npm not found! echo [ERROR] npm not found after Node.js install.
echo. set /a ERRORS+=1
echo npm is required for dependency management. goto :SUMMARY
echo.
pause
exit /b 1
) )
for /f "tokens=*" %%i in ('npm --version') do set NPM_VERSION=%%i for /f "tokens=*" %%i in ('npm --version') do set NPM_VERSION=%%i
echo [OK] npm found: %NPM_VERSION% echo [OK] npm: %NPM_VERSION%
echo. where git >nul 2>&1
echo [STEP 2/6] Checking OpenCode CLI... if %ERRORLEVEL% neq 0 (
echo. echo [INFO] Git not found. Attempting to install...
if %WINGET_AVAILABLE% equ 1 (
:: Check if opencode is already installed globally winget install -e --id Git.Git --accept-source-agreements --accept-package-agreements
where opencode >nul 2>&1 ) else if %CHOCO_AVAILABLE% equ 1 (
if %ERRORLEVEL% equ 0 ( choco install git -y
echo [OK] OpenCode is already installed globally ) else (
goto :OPENCODE_DONE echo [WARN] Git not installed (optional). Continue.
) set /a WARNINGS+=1
:: 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"
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
) else ( ) else (
echo [ERROR] opencode.exe not found in extracted files! for /f "tokens=*" %%i in ('git --version') do set GIT_VERSION=%%i
set /a ERRORS+=1 echo [OK] Git: %GIT_VERSION%
) )
: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.
echo [STEP 4/9] Installing npm dependencies
echo [STEP 3/6] Installing NomadArch Dependencies... cd /d "%SCRIPT_DIR%"
echo. echo [%date% %time%] Running npm install >> "%LOG_FILE%"
:: 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 call npm install
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [ERROR] Failed to install root dependencies! echo [ERROR] npm install failed!
echo [%date% %time%] ERROR: npm install failed >> "%LOG_FILE%"
set /a ERRORS+=1 set /a ERRORS+=1
goto :INSTALL_REPORT 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 [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 (
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'"
)
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
)
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
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 [OK] Root dependencies installed
echo. echo.
echo [STEP 6/9] Building UI assets
echo [INFO] Installing package dependencies... if exist "%SCRIPT_DIR%\packages\ui\dist\index.html" (
echo [OK] UI build already exists
:: Install server dependencies ) else (
if exist "packages\server" ( echo [INFO] Building UI assets...
echo [INFO] Installing server dependencies... pushd packages\ui
cd packages\server call npm run build
call npm install
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [WARN] Failed to install server dependencies! echo [ERROR] UI build failed!
set /a WARNINGS+=1 popd
) else ( set /a ERRORS+=1
echo [OK] Server dependencies installed goto :SUMMARY
) )
cd ..\.. popd
echo [OK] UI assets built successfully
) )
:: Install UI dependencies
if exist "packages\ui" (
echo [INFO] Installing UI dependencies...
cd packages\ui
call npm install
if %ERRORLEVEL% neq 0 (
echo [WARN] Failed to install UI dependencies!
set /a WARNINGS+=1
) 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
) else (
echo [OK] Electron app dependencies installed
)
cd ..\..
)
:BUILD_CHECK
echo. echo.
echo [STEP 7/9] Post-install health check
set HEALTH_ERRORS=0
echo [STEP 4/6] Checking for Existing Build... if not exist "%SCRIPT_DIR%\package.json" set /a HEALTH_ERRORS+=1
echo. 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 exist "packages\ui\dist" ( if %HEALTH_ERRORS% equ 0 (
echo [OK] UI build found. Skipping build step. echo [OK] Health checks passed
echo [INFO] To rebuild, delete packages\ui\dist and run installer again.
goto :INSTALL_REPORT
)
echo [INFO] No UI build found. Building UI...
echo.
:: Build UI
cd 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 ( ) else (
echo %ERRORS% error(s) encountered echo [ERROR] Health checks failed (%HEALTH_ERRORS%)
) set /a ERRORS+=%HEALTH_ERRORS%
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
set /a ERRORS+=1
)
:: Test npm command
npm --version >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo [OK] npm is working
) 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. echo.
echo [STEP 6/6] Next Steps... echo [STEP 8/9] Installation Summary
echo. echo.
echo To start NomadArch: echo Install Dir: %TARGET_DIR%
echo 1. Double-click and run: Launch-Windows.bat echo Architecture: %ARCH%
echo OR echo Node.js: %NODE_VERSION%
echo 2. Run from command line: npm run dev:electron echo npm: %NPM_VERSION%
echo. echo Errors: %ERRORS%
echo For development mode: echo Warnings: %WARNINGS%
echo Run: Launch-Dev-Windows.bat echo Log File: %LOG_FILE%
echo. echo.
echo [STEP 9/9] Next steps
:SUMMARY
if %ERRORS% gtr 0 ( if %ERRORS% gtr 0 (
echo ⚠ INSTALLATION HAD ERRORS! echo [RESULT] Installation completed with errors.
echo Please review the messages above and fix any issues. echo Review the log: %LOG_FILE%
echo. echo.
pause echo If Node.js was just installed, open a new terminal and run this installer again.
exit /b 1
) else ( ) else (
echo Installation completed successfully! echo [RESULT] Installation completed successfully.
echo. echo Run Launch-Windows.bat to start the application.
echo Press any key to exit...
pause >nul
exit /b 0
) )
echo.
echo Press any key to exit...
pause >nul
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 @echo off
title NomadArch Development Launcher
color 0B
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
title NomadArch Development Launcher
color 0B
echo. echo.
echo ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗ echo NomadArch Development Launcher (Windows)
echo ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║ echo Version: 0.4.0
echo ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║
echo ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║
echo ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║
echo ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
echo.
echo DEVELOPMENT MODE - Separate Server & UI Terminals
echo ═════════════════════════════════════════════════════════════════════════════
echo. 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... set ERRORS=0
echo. set WARNINGS=0
set AUTO_FIXED=0
echo [PREFLIGHT 1/7] Checking Dependencies...
where node >nul 2>&1 where node >nul 2>&1
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [ERROR] Node.js not found! echo [WARN] Node.js not found. Running installer...
echo. call "%SCRIPT_DIR%\Install-Windows.bat"
echo Please install Node.js first: https://nodejs.org/ echo [INFO] If Node.js was installed, open a new terminal and run Launch-Dev-Windows.bat again.
echo.
pause pause
exit /b 1 exit /b 1
) )
@@ -36,7 +34,6 @@ echo [OK] Node.js: %NODE_VERSION%
where npm >nul 2>&1 where npm >nul 2>&1
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [ERROR] npm not found! echo [ERROR] npm not found!
echo.
pause pause
exit /b 1 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 [OK] npm: %NPM_VERSION%
echo. echo.
echo [STEP 2/4] Checking for OpenCode CLI... echo [PREFLIGHT 2/7] Checking for OpenCode CLI...
echo.
where opencode >nul 2>&1 where opencode >nul 2>&1
if %ERRORLEVEL% equ 0 ( if %ERRORLEVEL% equ 0 (
echo [OK] OpenCode is available in PATH echo [OK] OpenCode CLI available in PATH
) else ( ) else (
if exist "bin\opencode.exe" ( if exist "bin\opencode.exe" (
echo [OK] OpenCode binary found in bin/ folder echo [OK] OpenCode binary found in bin/
) else ( ) else (
echo [WARN] OpenCode CLI not found 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.
echo [STEP 3/4] Checking Port Availability... echo [PREFLIGHT 3/7] Checking Dependencies...
echo.
set SERVER_PORT=3001 if not exist "node_modules" (
set UI_PORT=3000 echo [INFO] Dependencies not installed. Installing now...
call npm install
netstat -ano | findstr ":%SERVER_PORT%" | findstr "LISTENING" >nul 2>&1 if %ERRORLEVEL% neq 0 (
if %ERRORLEVEL% equ 0 ( echo [ERROR] Dependency installation failed!
echo [WARN] Port %SERVER_PORT% is already in use pause
echo [INFO] Another NomadArch instance may be running exit /b 1
echo [INFO] To find process: netstat -ano | findstr ":%SERVER_PORT%" )
echo [INFO] To kill it: taskkill /F /PID ^<PID^> echo [OK] Dependencies installed (auto-fix)
set /a AUTO_FIXED+=1
) else ( ) else (
echo [OK] Port %SERVER_PORT% is available echo [OK] Dependencies found
)
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. echo.
echo [STEP 4/4] Starting NomadArch in Development Mode... echo [PREFLIGHT 4/7] Finding Available Ports...
echo.
echo [INFO] This will open 3 separate terminal windows: set DEFAULT_SERVER_PORT=3001
echo 1. Backend Server (port 3001) set DEFAULT_UI_PORT=3000
echo 2. Frontend UI (port 3000) set SERVER_PORT=%DEFAULT_SERVER_PORT%
echo 3. Electron App set UI_PORT=%DEFAULT_UI_PORT%
echo.
echo [INFO] Press any key to start... for /l %%p in (%DEFAULT_SERVER_PORT%,1,3050) do (
pause >nul 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.
echo [INFO] Starting Backend Server... echo [PREFLIGHT 5/7] Final Checks...
start "NomadArch Server" cmd /k "cd /d \"%~dp0packages\server\" && npm run dev"
echo [INFO] Starting Frontend UI... if not exist "packages\ui\dist\index.html" (
start "NomadArch UI" cmd /k "cd /d \"%~dp0packages\ui\" && npm run dev" 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" start "NomadArch Electron" cmd /k "cd /d \"%~dp0packages\electron-app\" && npm run dev"
echo. echo.
echo [OK] All services started! echo [OK] All services started.
echo. echo Press any key to stop all services...
echo Press any key to stop all services (Ctrl+C in each window also works)...
pause >nul pause >nul
echo.
echo [INFO] Stopping all services...
taskkill /F /FI "WINDOWTITLE eq NomadArch*" >nul 2>&1 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 Server*" >nul 2>&1
taskkill /F /FI "WINDOWTITLE eq NomadArch UI*" >nul 2>&1 taskkill /F /FI "WINDOWTITLE eq NomadArch UI*" >nul 2>&1
taskkill /F /FI "WINDOWTITLE eq NomadArch Electron*" >nul 2>&1 taskkill /F /FI "WINDOWTITLE eq NomadArch Electron*" >nul 2>&1
echo [OK] All services stopped. :launch_check
echo.
pause pause
exit /b %ERRORS%

View File

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

View File

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

View File

@@ -1,35 +1,29 @@
@echo off @echo off
title NomadArch Launcher
color 0A
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
title NomadArch Launcher
color 0A
echo. echo.
echo ███╗ ██╗ ██████╗ ███╗ ███╗ █████╗ ██████╗ █████╗ ██████╗ ██████╗██╗ ██╗ echo NomadArch Launcher (Windows)
echo ████╗ ██║██╔═══██╗████╗ ████║██╔══██╗██╔══██╗██╔══██╗██╔════╝██║ ██║ echo Version: 0.4.0
echo ██╔██╗ ██║██║ ██║██╔████╔██║███████║██║ ██║███████║██████╔╝██║ ███████║
echo ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║
echo ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║
echo ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
echo.
echo LAUNCHER - Enhanced with Auto-Fix Capabilities
echo ═════════════════════════════════════════════════════════════════════════════
echo. echo.
cd /d "%~dp0" set SCRIPT_DIR=%~dp0
set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
cd /d "%SCRIPT_DIR%"
set ERRORS=0 set ERRORS=0
set WARNINGS=0 set WARNINGS=0
set AUTO_FIXED=0
echo [STEP 1/5] Checking Dependencies... echo [PREFLIGHT 1/7] Checking Dependencies...
echo.
where node >nul 2>&1 where node >nul 2>&1
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [ERROR] Node.js not found! echo [WARN] Node.js not found. Running installer...
echo. call "%SCRIPT_DIR%\Install-Windows.bat"
echo Please install Node.js first: https://nodejs.org/ echo [INFO] If Node.js was installed, open a new terminal and run Launch-Windows.bat again.
echo Then run: Install-Windows.bat
echo.
pause pause
exit /b 1 exit /b 1
) )
@@ -40,7 +34,6 @@ echo [OK] Node.js: %NODE_VERSION%
where npm >nul 2>&1 where npm >nul 2>&1
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [ERROR] npm not found! echo [ERROR] npm not found!
echo.
pause pause
exit /b 1 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 [OK] npm: %NPM_VERSION%
echo. echo.
echo [STEP 2/5] Checking for OpenCode CLI... echo [PREFLIGHT 2/7] Checking for OpenCode CLI...
echo.
where opencode >nul 2>&1 where opencode >nul 2>&1
if %ERRORLEVEL% equ 0 ( if %ERRORLEVEL% equ 0 (
echo [OK] OpenCode is available in PATH echo [OK] OpenCode CLI available in PATH
) else ( ) else (
if exist "bin\opencode.exe" ( if exist "bin\opencode.exe" (
echo [OK] OpenCode binary found in bin/ folder echo [OK] OpenCode binary found in bin/
) else ( ) else (
echo [WARN] OpenCode CLI not found 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 set /a WARNINGS+=1
) )
) )
echo. echo.
echo [STEP 3/5] Checking for Existing Build... echo [PREFLIGHT 3/7] Checking Dependencies...
echo.
if exist "packages\ui\dist" ( if not exist "node_modules" (
echo [OK] UI build found 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 ( ) else (
echo [WARN] No UI build found. Building now... echo [OK] Dependencies found
echo. )
cd packages\ui
echo.
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 call npm run build
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo [ERROR] UI build failed! echo [ERROR] UI build failed!
popd
set /a ERRORS+=1 set /a ERRORS+=1
) else ( goto :final_launch_check
echo [OK] UI build completed
) )
cd ..\.. popd
) echo [OK] UI build completed (auto-fix)
set /a AUTO_FIXED+=1
echo.
echo [STEP 4/5] Checking Port Availability...
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 ( ) else (
echo [OK] Port %SERVER_PORT% is available echo [OK] UI build directory exists
) )
netstat -ano | findstr ":%UI_PORT%" | findstr "LISTENING" >nul 2>&1 if not exist "packages\electron-app\dist\main\main.js" (
if %ERRORLEVEL% equ 0 ( echo [WARN] Electron build incomplete
echo [WARN] Port %UI_PORT% is already in use echo [INFO] Running full build...
echo [INFO] To find the process: netstat -ano | findstr ":%UI_PORT%" call npm run build
echo [INFO] To kill it: taskkill /F /PID <PID> if %ERRORLEVEL% neq 0 (
set /a WARNINGS+=1 echo [ERROR] Full build failed!
) else ( set /a ERRORS+=1
echo [OK] Port %UI_PORT% is available goto :final_launch_check
)
echo [OK] Full build completed (auto-fix)
set /a AUTO_FIXED+=1
) )
echo. echo.
echo [STEP 5/5] Starting NomadArch... 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. echo.
if %ERRORS% gtr 0 ( if %ERRORS% gtr 0 (
echo [ERROR] Cannot start due to errors! echo [RESULT] Cannot start due to errors!
echo. echo.
echo Please fix the errors above and try again.
pause pause
exit /b 1 exit /b 1
) )
echo [INFO] Starting NomadArch... 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 [INFO] Press Ctrl+C to stop
echo. 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 call npm run dev:electron
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (
echo. echo.
echo [ERROR] NomadArch exited with an error! echo [ERROR] NomadArch exited with an error!
echo. echo.
echo Common solutions: echo Error Code: %ERRORLEVEL%
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.
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. 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": { "scripts": {
"dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app", "dev": "npm run dev:electron --workspace @neuralnomads/codenomad-electron-app",
"dev:electron": "npm run dev --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", "dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
"build": "npm run build --workspace @neuralnomads/codenomad-electron-app", "build": "npm run build --workspace @neuralnomads/codenomad-electron-app",
"build:tauri": "npm run build --workspace @codenomad/tauri-app", "build:tauri": "npm run build --workspace @codenomad/tauri-app",
@@ -23,5 +23,11 @@
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.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 { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import path from "path"
import type { CliProcessManager, CliStatus } from "./process-manager" import type { CliProcessManager, CliStatus } from "./process-manager"
import {
listUsers,
createUser,
updateUser,
deleteUser,
verifyPassword,
setActiveUser,
createGuestUser,
getActiveUser,
getUserDataRoot,
} from "./user-store"
interface DialogOpenRequest { interface DialogOpenRequest {
mode: "directory" | "file" mode: "directory" | "file"
@@ -40,6 +52,41 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return cliManager.start({ dev: devMode }) 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> => { ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] = const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"] request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu" import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc" import { setupCliIPC } from "./ipc"
import { CliProcessManager } from "./process-manager" import { CliProcessManager } from "./process-manager"
import { ensureDefaultUsers, getActiveUser, getUserDataRoot, clearGuestUsers } from "./user-store"
const mainFilename = fileURLToPath(import.meta.url) const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename) const mainDirname = dirname(mainFilename)
@@ -225,6 +226,24 @@ function getPreloadPath() {
return join(mainDirname, "../preload/index.js") 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) { function destroyPreloadingView(target?: BrowserView | null) {
const view = target ?? preloadingView const view = target ?? preloadingView
if (!view) { if (!view) {
@@ -274,7 +293,7 @@ function createWindow() {
currentCliUrl = null currentCliUrl = null
loadLoadingScreen(mainWindow) 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" }) mainWindow.webContents.openDevTools({ mode: "detach" })
} }
@@ -452,6 +471,8 @@ if (isMac) {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
ensureDefaultUsers()
applyUserEnvToCli()
startCli() startCli()
if (isMac) { if (isMac) {
@@ -480,6 +501,7 @@ app.whenReady().then(() => {
app.on("before-quit", async (event) => { app.on("before-quit", async (event) => {
event.preventDefault() event.preventDefault()
await cliManager.stop().catch(() => { }) await cliManager.stop().catch(() => { })
clearGuestUsers()
app.exit(0) app.exit(0)
}) })

View File

@@ -79,6 +79,11 @@ export class CliProcessManager extends EventEmitter {
private status: CliStatus = { state: "stopped" } private status: CliStatus = { state: "stopped" }
private stdoutBuffer = "" private stdoutBuffer = ""
private stderrBuffer = "" private stderrBuffer = ""
private userEnv: Record<string, string> = {}
setUserEnv(env: Record<string, string>) {
this.userEnv = { ...env }
}
async start(options: StartOptions): Promise<CliStatus> { async start(options: StartOptions): Promise<CliStatus> {
if (this.child) { if (this.child) {
@@ -100,6 +105,7 @@ export class CliProcessManager extends EventEmitter {
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1" env.ELECTRON_RUN_AS_NODE = "1"
Object.assign(env, this.userEnv)
const spawnDetails = supportsUserShell() const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) ? 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"] const args = ["serve", "--host", host, "--port", "0"]
if (options.dev) { 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 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"), getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"), restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options), 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) contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -15,7 +15,7 @@
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad", "homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": { "scripts": {
"dev": "electron-vite dev", "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", "build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview", "preview": "electron-vite preview",
@@ -40,6 +40,7 @@
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0", "app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0", "electron": "39.0.0",
"electron-builder": "^24.0.0", "electron-builder": "^24.0.0",
"electron-vite": "4.0.1", "electron-vite": "4.0.1",

View File

@@ -79,6 +79,37 @@ export type WorkspaceCreateResponse = WorkspaceDescriptor
export type WorkspaceListResponse = WorkspaceDescriptor[] export type WorkspaceListResponse = WorkspaceDescriptor[]
export type WorkspaceDetailResponse = 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 { export interface WorkspaceDeleteResponse {
id: string id: string
status: WorkspaceStatus status: WorkspaceStatus
@@ -159,6 +190,11 @@ export interface InstanceData {
agentModelSelections: AgentModelSelection agentModelSelections: AgentModelSelection
sessionTasks?: SessionTasks // Multi-task chat support: tasks per session sessionTasks?: SessionTasks // Multi-task chat support: tasks per session
sessionSkills?: Record<string, SkillSelection[]> // Selected skills 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" export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
@@ -269,6 +305,10 @@ export interface ServerMeta {
latestRelease?: LatestReleaseInfo latestRelease?: LatestReleaseInfo
} }
export interface PortAvailabilityResponse {
port: number
}
export type { export type {
Preferences, Preferences,
ModelPreference, ModelPreference,

View File

@@ -16,6 +16,7 @@ import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store" import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events" import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { getUserConfigPath } from "./user-data"
import { launchInBrowser } from "./launcher" import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor" import { startReleaseMonitor } from "./releases/release-monitor"
@@ -41,7 +42,7 @@ interface CliOptions {
const DEFAULT_PORT = 9898 const DEFAULT_PORT = 9898
const DEFAULT_HOST = "127.0.0.1" 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 { function parseCliOptions(argv: string[]): CliOptions {
const program = new Command() 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" import { z } from "zod"
// Configuration schema for Ollama Cloud
export const OllamaCloudConfigSchema = z.object({ export const OllamaCloudConfigSchema = z.object({
apiKey: z.string().optional(), apiKey: z.string().optional(),
endpoint: z.string().default("https://ollama.com"), endpoint: z.string().default("https://ollama.com"),
@@ -14,31 +8,56 @@ export const OllamaCloudConfigSchema = z.object({
export type OllamaCloudConfig = z.infer<typeof OllamaCloudConfigSchema> 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({ export const OllamaModelSchema = z.object({
name: z.string(), name: z.string(),
size: z.string(), model: z.string().optional(), // Some APIs return model instead of name
digest: z.string(), size: z.union([z.string(), z.number()]).optional(),
modified_at: z.string(), digest: z.string().optional(),
created_at: z.string() 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> export type OllamaModel = z.infer<typeof OllamaModelSchema>
// Chat message schema
export const ChatMessageSchema = z.object({ export const ChatMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]), role: z.enum(["user", "assistant", "system"]),
content: z.string(), 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> 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({ export const ChatRequestSchema = z.object({
model: z.string(), model: z.string(),
messages: z.array(ChatMessageSchema), messages: z.array(ChatMessageSchema),
stream: z.boolean().default(false), 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({ options: z.object({
temperature: z.number().min(0).max(2).optional(), temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional() top_p: z.number().min(0).max(1).optional()
@@ -48,7 +67,10 @@ export const ChatRequestSchema = z.object({
export const ChatResponseSchema = z.object({ export const ChatResponseSchema = z.object({
model: z.string(), model: z.string(),
created_at: 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(), done: z.boolean().optional(),
total_duration: z.number().optional(), total_duration: z.number().optional(),
load_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 ChatRequest = z.infer<typeof ChatRequestSchema>
export type ChatResponse = z.infer<typeof ChatResponseSchema> 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 { export class OllamaCloudClient {
private config: OllamaCloudConfig private config: OllamaCloudConfig
private baseUrl: string private baseUrl: string
constructor(config: OllamaCloudConfig) { constructor(config: OllamaCloudConfig) {
this.config = config 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> { async testConnection(): Promise<boolean> {
try { try {
const response = await this.makeRequest("/api/tags", { const response = await this.makeRequest("/tags", { method: "GET" })
method: "GET"
})
return response.ok return response.ok
} catch (error) { } catch (error) {
console.error("Ollama Cloud connection test failed:", error) console.error("Ollama Cloud connection test failed:", error)
@@ -85,30 +116,85 @@ export class OllamaCloudClient {
} }
} }
/**
* List available models
*/
async listModels(): Promise<OllamaModel[]> { async listModels(): Promise<OllamaModel[]> {
try { try {
const response = await this.makeRequest("/api/tags", { const headers: Record<string, string> = {}
method: "GET" 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) { 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() 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) { } catch (error) {
console.error("Failed to list Ollama Cloud models:", error) console.error("Failed to list Ollama Cloud models:", error)
throw error throw error
} }
} }
/**
* Generate chat completion
*/
async chat(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> { async chat(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
if (!this.config.apiKey) { if (!this.config.apiKey) {
throw new Error("Ollama Cloud API key is required") throw new Error("Ollama Cloud API key is required")
@@ -118,20 +204,20 @@ export class OllamaCloudClient {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
// Add authorization header if API key is provided
if (this.config.apiKey) { if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}` headers["Authorization"] = `Bearer ${this.config.apiKey}`
} }
try { try {
const response = await fetch(`${this.baseUrl}/api/chat`, { const response = await this.makeRequest("/chat", {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify(request) body: JSON.stringify(request)
}) })
if (!response.ok) { 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) { if (request.stream) {
@@ -146,9 +232,85 @@ export class OllamaCloudClient {
} }
} }
/** async chatWithThinking(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
* Pull a model (for cloud models, this just makes them available) 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> { async pullModel(modelName: string): Promise<void> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json" "Content-Type": "application/json"
@@ -158,7 +320,7 @@ export class OllamaCloudClient {
headers["Authorization"] = `Bearer ${this.config.apiKey}` headers["Authorization"] = `Bearer ${this.config.apiKey}`
} }
const response = await fetch(`${this.baseUrl}/api/pull`, { const response = await this.makeRequest("/pull", {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ name: modelName }) body: JSON.stringify({ name: modelName })
@@ -169,9 +331,6 @@ export class OllamaCloudClient {
} }
} }
/**
* Parse streaming response
*/
private async *parseStreamingResponse(response: Response): AsyncIterable<ChatResponse> { private async *parseStreamingResponse(response: Response): AsyncIterable<ChatResponse> {
if (!response.body) { if (!response.body) {
throw new Error("Response body is missing") throw new Error("Response body is missing")
@@ -186,18 +345,17 @@ export class OllamaCloudClient {
if (done) break if (done) break
const lines = decoder.decode(value, { stream: true }).split('\n').filter(line => line.trim()) const lines = decoder.decode(value, { stream: true }).split('\n').filter(line => line.trim())
for (const line of lines) { for (const line of lines) {
try { try {
const data = JSON.parse(line) const data = JSON.parse(line)
const chatResponse = ChatResponseSchema.parse(data) const chatResponse = ChatResponseSchema.parse(data)
yield chatResponse yield chatResponse
if (chatResponse.done) { if (chatResponse.done) {
return return
} }
} catch (parseError) { } catch (parseError) {
// Skip invalid JSON lines
console.warn("Failed to parse streaming line:", line, parseError) 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> { private async *createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
for (const item of items) { for (const item of items) {
yield item yield item
} }
} }
/**
* Make authenticated request to API
*/
private async makeRequest(endpoint: string, options: RequestInit): Promise<Response> { 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> = { const headers: Record<string, string> = {
...options.headers as Record<string, string> ...options.headers as Record<string, string>
} }
// Add authorization header if API key is provided
if (this.config.apiKey) { if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}` headers["Authorization"] = `Bearer ${this.config.apiKey}`
} }
console.log(`[OllamaCloud] Making request to: ${url}`)
return fetch(url, { return fetch(url, {
...options, ...options,
headers headers
}) })
} }
/**
* Get cloud-specific models (models ending with -cloud)
*/
async getCloudModels(): Promise<OllamaModel[]> { async getCloudModels(): Promise<OllamaModel[]> {
const allModels = await this.listModels() const allModels = await this.listModels()
return allModels.filter(model => model.name.endsWith("-cloud")) return allModels.filter(model => model.name.endsWith("-cloud"))
} }
/**
* Validate API key format
*/
static validateApiKey(apiKey: string): boolean { static validateApiKey(apiKey: string): boolean {
return typeof apiKey === "string" && apiKey.length > 0 return typeof apiKey === "string" && apiKey.length > 0
} }
/**
* Get available cloud model names
*/
async getCloudModelNames(): Promise<string[]> { async getCloudModelNames(): Promise<string[]> {
const cloudModels = await this.getCloudModels() const cloudModels = await this.getCloudModels()
return cloudModels.map(model => model.name) 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 = [ export const DEFAULT_CLOUD_MODELS = [
"gpt-oss:120b-cloud", "gpt-oss:120b-cloud",
"llama3.1:70b-cloud", "llama3.1:70b-cloud",
@@ -270,4 +439,32 @@ export const DEFAULT_CLOUD_MODELS = [
"qwen2.5:7b-cloud" "qwen2.5:7b-cloud"
] as const ] as const
export type CloudModelName = typeof DEFAULT_CLOUD_MODELS[number] 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 // Configuration schema for OpenCode Zen
export const OpenCodeZenConfigSchema = z.object({ export const OpenCodeZenConfigSchema = z.object({
enabled: z.boolean().default(true), // Free models enabled by default enabled: z.boolean().default(true), // Free models enabled by default
endpoint: z.string().default("https://api.opencode.ai/v1"), endpoint: z.string().default("https://opencode.ai/zen/v1"),
apiKey: z.string().default("public") // "public" key for free models apiKey: z.string().optional()
}) })
export type OpenCodeZenConfig = z.infer<typeof OpenCodeZenConfigSchema> export type OpenCodeZenConfig = z.infer<typeof OpenCodeZenConfigSchema>
@@ -104,10 +104,10 @@ export const FREE_ZEN_MODELS: ZenModel[] = [
attachment: false, attachment: false,
temperature: true, temperature: true,
cost: { input: 0, output: 0 }, 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", name: "Grok Code Fast 1",
family: "grok", family: "grok",
reasoning: true, reasoning: true,
@@ -115,18 +115,29 @@ export const FREE_ZEN_MODELS: ZenModel[] = [
attachment: false, attachment: false,
temperature: true, temperature: true,
cost: { input: 0, output: 0 }, cost: { input: 0, output: 0 },
limit: { context: 256000, output: 10000 } limit: { context: 256000, output: 256000 }
}, },
{ {
id: "minimax-m2.1", id: "glm-4.7-free",
name: "MiniMax M2.1", name: "GLM-4.7",
family: "minimax", family: "glm-free",
reasoning: true, reasoning: true,
tool_call: true, tool_call: true,
attachment: false, attachment: false,
temperature: true, temperature: true,
cost: { input: 0, output: 0 }, 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) * Chat completion (streaming)
*/ */
async *chatStream(request: ChatRequest): AsyncGenerator<ChatChunk> { 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`, { const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST", method: "POST",
headers: { headers,
"Content-Type": "application/json",
"Authorization": `Bearer ${this.config.apiKey}`,
"User-Agent": "NomadArch/1.0"
},
body: JSON.stringify({ body: JSON.stringify({
...request, ...request,
stream: true stream: true
@@ -281,13 +298,19 @@ export class OpenCodeZenClient {
* Chat completion (non-streaming) * Chat completion (non-streaming)
*/ */
async chat(request: ChatRequest): Promise<ChatChunk> { 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`, { const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST", method: "POST",
headers: { headers,
"Content-Type": "application/json",
"Authorization": `Bearer ${this.config.apiKey}`,
"User-Agent": "NomadArch/1.0"
},
body: JSON.stringify({ body: JSON.stringify({
...request, ...request,
stream: false stream: false
@@ -306,7 +329,6 @@ export class OpenCodeZenClient {
export function getDefaultZenConfig(): OpenCodeZenConfig { export function getDefaultZenConfig(): OpenCodeZenConfig {
return { return {
enabled: true, enabled: true,
endpoint: "https://api.opencode.ai/v1", endpoint: "https://opencode.ai/zen/v1"
apiKey: "public"
} }
} }

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" import { z } from "zod"
// Configuration schema for Z.AI
export const ZAIConfigSchema = z.object({ export const ZAIConfigSchema = z.object({
apiKey: z.string().optional(), 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), 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> export type ZAIConfig = z.infer<typeof ZAIConfigSchema>
// Message schema (Anthropic-compatible)
export const ZAIMessageSchema = z.object({ export const ZAIMessageSchema = z.object({
role: z.enum(["user", "assistant"]), role: z.enum(["user", "assistant", "system"]),
content: z.string() content: z.string()
}) })
export type ZAIMessage = z.infer<typeof ZAIMessageSchema> export type ZAIMessage = z.infer<typeof ZAIMessageSchema>
// Chat request schema
export const ZAIChatRequestSchema = z.object({ export const ZAIChatRequestSchema = z.object({
model: z.string().default("claude-sonnet-4-20250514"), model: z.string().default("glm-4.7"),
messages: z.array(ZAIMessageSchema), messages: z.array(ZAIMessageSchema),
max_tokens: z.number().default(8192), max_tokens: z.number().default(8192),
stream: z.boolean().default(true), 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> export type ZAIChatRequest = z.infer<typeof ZAIChatRequestSchema>
// Chat response schema
export const ZAIChatResponseSchema = z.object({ export const ZAIChatResponseSchema = z.object({
id: z.string(), id: z.string(),
type: z.string(), object: z.string(),
role: z.string(), created: z.number(),
content: z.array(z.object({
type: z.string(),
text: z.string().optional()
})),
model: z.string(), model: z.string(),
stop_reason: z.string().nullable().optional(), choices: z.array(z.object({
stop_sequence: z.string().nullable().optional(), 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({ usage: z.object({
input_tokens: z.number(), prompt_tokens: z.number(),
output_tokens: z.number() completion_tokens: z.number(),
}).optional() total_tokens: z.number()
})
}) })
export type ZAIChatResponse = z.infer<typeof ZAIChatResponseSchema> export type ZAIChatResponse = z.infer<typeof ZAIChatResponseSchema>
// Stream chunk schema
export const ZAIStreamChunkSchema = z.object({ export const ZAIStreamChunkSchema = z.object({
type: z.string(), id: z.string(),
index: z.number().optional(), object: z.string(),
delta: z.object({ created: z.number(),
type: z.string().optional(), model: z.string(),
text: z.string().optional() choices: z.array(z.object({
}).optional(), index: z.number(),
message: z.object({ delta: z.object({
id: z.string(), role: z.string().optional(),
type: z.string(), content: z.string().optional(),
role: z.string(), reasoning_content: z.string().optional()
content: z.array(z.any()), }),
model: z.string() finish_reason: z.string().nullable().optional()
}).optional(), }))
content_block: z.object({
type: z.string(),
text: z.string()
}).optional()
}) })
export type ZAIStreamChunk = z.infer<typeof ZAIStreamChunkSchema> 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 { export class ZAIClient {
private config: ZAIConfig private config: ZAIConfig
private baseUrl: string private baseUrl: string
constructor(config: ZAIConfig) { constructor(config: ZAIConfig) {
this.config = config 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> { async testConnection(): Promise<boolean> {
if (!this.config.apiKey) { if (!this.config.apiKey) {
return false return false
} }
try { try {
// Make a minimal request to test auth const response = await fetch(`${this.baseUrl}/chat/completions`, {
const response = await fetch(`${this.baseUrl}/v1/messages`, {
method: "POST", method: "POST",
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify({ body: JSON.stringify({
model: "claude-sonnet-4-20250514", model: "glm-4.7",
max_tokens: 1, max_tokens: 1,
messages: [{ role: "user", content: "test" }] messages: [{ role: "user", content: "test" }]
}) })
}) })
// Any response other than auth error means connection works
return response.status !== 401 && response.status !== 403 return response.status !== 401 && response.status !== 403
} catch (error) { } catch (error) {
console.error("Z.AI connection test failed:", error) console.error("Z.AI connection test failed:", error)
@@ -115,28 +113,16 @@ export class ZAIClient {
} }
} }
/**
* List available models
*/
async listModels(): Promise<string[]> { async listModels(): Promise<string[]> {
// Z.AI provides access to Claude models through their proxy return [...ZAI_MODELS]
return [
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-opus-20240229",
"claude-3-haiku-20240307"
]
} }
/**
* Chat completion (streaming)
*/
async *chatStream(request: ZAIChatRequest): AsyncGenerator<ZAIStreamChunk> { async *chatStream(request: ZAIChatRequest): AsyncGenerator<ZAIStreamChunk> {
if (!this.config.apiKey) { if (!this.config.apiKey) {
throw new Error("Z.AI API key is required") 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", method: "POST",
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify({ body: JSON.stringify({
@@ -165,7 +151,7 @@ export class ZAIClient {
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n") const lines = buffer.split("\n")
buffer = lines.pop() || "" // Keep incomplete line in buffer buffer = lines.pop() || ""
for (const line of lines) { for (const line of lines) {
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) {
@@ -176,7 +162,6 @@ export class ZAIClient {
const parsed = JSON.parse(data) const parsed = JSON.parse(data)
yield parsed as ZAIStreamChunk yield parsed as ZAIStreamChunk
} catch (e) { } catch (e) {
// Skip invalid JSON
} }
} }
} }
@@ -186,15 +171,12 @@ export class ZAIClient {
} }
} }
/**
* Chat completion (non-streaming)
*/
async chat(request: ZAIChatRequest): Promise<ZAIChatResponse> { async chat(request: ZAIChatRequest): Promise<ZAIChatResponse> {
if (!this.config.apiKey) { if (!this.config.apiKey) {
throw new Error("Z.AI API key is required") 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", method: "POST",
headers: this.getHeaders(), headers: this.getHeaders(),
body: JSON.stringify({ body: JSON.stringify({
@@ -211,31 +193,14 @@ export class ZAIClient {
return await response.json() return await response.json()
} }
/**
* Get request headers
*/
private getHeaders(): Record<string, string> { private getHeaders(): Record<string, string> {
return { return {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-api-key": this.config.apiKey || "", "Authorization": `Bearer ${this.config.apiKey}`
"anthropic-version": "2023-06-01"
} }
} }
/**
* Validate API key
*/
static validateApiKey(apiKey: string): boolean { static validateApiKey(apiKey: string): boolean {
return typeof apiKey === "string" && apiKey.length > 0 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 path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { getOpencodeWorkspacesRoot, getUserDataRoot } from "./user-data"
const log = createLogger({ component: "opencode-config" }) const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url) 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 isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild ? devTemplateDir : prodTemplateDir 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 { export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) { if (!existsSync(templateDir)) {
@@ -28,6 +30,28 @@ export function getOpencodeConfigDir(): string {
return userConfigDir 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() { function refreshUserConfig() {
log.debug({ templateDir, userConfigDir }, "Syncing Opencode config template") log.debug({ templateDir, userConfigDir }, "Syncing Opencode config template")
rmSync(userConfigDir, { recursive: true, force: true }) 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 }) registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
@@ -119,7 +123,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerQwenRoutes(app, { logger: deps.logger }) registerQwenRoutes(app, { logger: deps.logger })
registerZAIRoutes(app, { logger: deps.logger }) registerZAIRoutes(app, { logger: deps.logger })
registerOpenCodeZenRoutes(app, { logger: deps.logger }) registerOpenCodeZenRoutes(app, { logger: deps.logger })
await registerSkillsRoutes(app) registerSkillsRoutes(app)
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import os from "os" import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types" import { NetworkAddress, ServerMeta, PortAvailabilityResponse } from "../../api-types"
import { getAvailablePort } from "../../utils/port"
interface RouteDeps { interface RouteDeps {
serverMeta: ServerMeta serverMeta: ServerMeta
@@ -8,6 +9,11 @@ interface RouteDeps {
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta)) 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 { function buildMetaResponse(meta: ServerMeta): ServerMeta {

View File

@@ -1,6 +1,18 @@
import { FastifyInstance, FastifyReply } from "fastify" 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 { 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 { interface OllamaRouteDeps {
logger: Logger logger: Logger
@@ -12,7 +24,6 @@ export async function registerOllamaRoutes(
) { ) {
const logger = deps.logger.child({ component: "ollama-routes" }) const logger = deps.logger.child({ component: "ollama-routes" })
// Get Ollama Cloud configuration
app.get('/api/ollama/config', async (request, reply) => { app.get('/api/ollama/config', async (request, reply) => {
try { try {
const config = getOllamaConfig() const config = getOllamaConfig()
@@ -23,15 +34,16 @@ export async function registerOllamaRoutes(
} }
}) })
// Update Ollama Cloud configuration
app.post('/api/ollama/config', { app.post('/api/ollama/config', {
schema: { schema: {
type: 'object', body: {
required: ['enabled'], type: 'object',
properties: { required: ['enabled'],
enabled: { type: 'boolean' }, properties: {
apiKey: { type: 'string' }, enabled: { type: 'boolean' },
endpoint: { type: 'string' } apiKey: { type: 'string' },
endpoint: { type: 'string' }
}
} }
} }
}, async (request, reply) => { }, async (request, reply) => {
@@ -46,7 +58,6 @@ export async function registerOllamaRoutes(
} }
}) })
// Test Ollama Cloud connection
app.post('/api/ollama/test', async (request, reply) => { app.post('/api/ollama/test', async (request, reply) => {
try { try {
const config = getOllamaConfig() const config = getOllamaConfig()
@@ -56,7 +67,7 @@ export async function registerOllamaRoutes(
const client = new OllamaCloudClient(config) const client = new OllamaCloudClient(config)
const isConnected = await client.testConnection() const isConnected = await client.testConnection()
return { connected: isConnected } return { connected: isConnected }
} catch (error) { } catch (error) {
logger.error({ error }, "Ollama Cloud connection test failed") logger.error({ error }, "Ollama Cloud connection test failed")
@@ -64,7 +75,6 @@ export async function registerOllamaRoutes(
} }
}) })
// List available models
app.get('/api/ollama/models', async (request, reply) => { app.get('/api/ollama/models', async (request, reply) => {
try { try {
const config = getOllamaConfig() const config = getOllamaConfig()
@@ -72,17 +82,19 @@ export async function registerOllamaRoutes(
return reply.status(400).send({ error: "Ollama Cloud is not enabled" }) 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 client = new OllamaCloudClient(config)
const models = await client.listModels() const models = await client.listModels()
logger.info({ modelCount: models.length }, "Ollama models fetched successfully")
return { models } return { models }
} catch (error) { } catch (error: any) {
logger.error({ error }, "Failed to list Ollama models") logger.error({ error: error?.message || error }, "Failed to list Ollama models")
return reply.status(500).send({ error: "Failed to list 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) => { app.get('/api/ollama/models/cloud', async (request, reply) => {
try { try {
const config = getOllamaConfig() const config = getOllamaConfig()
@@ -92,7 +104,7 @@ export async function registerOllamaRoutes(
const client = new OllamaCloudClient(config) const client = new OllamaCloudClient(config)
const cloudModels = await client.getCloudModels() const cloudModels = await client.getCloudModels()
return { models: cloudModels } return { models: cloudModels }
} catch (error) { } catch (error) {
logger.error({ error }, "Failed to list cloud models") logger.error({ error }, "Failed to list cloud models")
@@ -100,30 +112,86 @@ 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', { app.post('/api/ollama/chat', {
schema: { schema: {
type: 'object', body: {
required: ['model', 'messages'], type: 'object',
properties: { required: ['model', 'messages'],
model: { type: 'string' }, properties: {
messages: { model: { type: 'string' },
type: 'array', messages: {
items: { type: 'array',
type: 'object', items: {
required: ['role', 'content'], type: 'object',
properties: { required: ['role', 'content'],
role: { type: 'string', enum: ['user', 'assistant', 'system'] }, properties: {
content: { type: 'string' } role: { type: 'string', enum: ['user', 'assistant', 'system'] },
content: { type: 'string' }
}
}
},
stream: { type: 'boolean' },
think: { type: ['boolean', 'string'] },
format: { type: ['string', 'object'] },
tools: { type: 'array' },
web_search: { type: 'boolean' },
options: {
type: 'object',
properties: {
temperature: { type: 'number', minimum: 0, maximum: 2 },
top_p: { type: 'number', minimum: 0, maximum: 1 }
} }
}
},
stream: { type: 'boolean' },
options: {
type: 'object',
properties: {
temperature: { type: 'number', minimum: 0, maximum: 2 },
top_p: { type: 'number', minimum: 0, maximum: 1 }
} }
} }
} }
@@ -137,8 +205,7 @@ export async function registerOllamaRoutes(
const client = new OllamaCloudClient(config) const client = new OllamaCloudClient(config)
const chatRequest = request.body as ChatRequest const chatRequest = request.body as ChatRequest
// Set appropriate headers for streaming
if (chatRequest.stream) { if (chatRequest.stream) {
reply.raw.writeHead(200, { reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -148,24 +215,31 @@ export async function registerOllamaRoutes(
try { try {
const stream = await client.chat(chatRequest) const stream = await client.chat(chatRequest)
for await (const chunk of stream) { for await (const chunk of stream) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`) reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
if (chunk.done) { if (chunk.done) {
reply.raw.write('data: [DONE]\n\n') reply.raw.write('data: [DONE]\n\n')
break break
} }
} }
reply.raw.end() reply.raw.end()
} catch (streamError) { } catch (streamError: any) {
logger.error({ error: streamError }, "Streaming failed") 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() reply.raw.end()
} }
} else { } else {
const response = await client.chat(chatRequest) const stream = await client.chat(chatRequest)
return response const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
} }
} catch (error) { } catch (error) {
logger.error({ error }, "Ollama chat request failed") logger.error({ error }, "Ollama chat request failed")
@@ -173,13 +247,289 @@ 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', { app.post('/api/ollama/pull', {
schema: { schema: {
type: 'object', body: {
required: ['model'], type: 'object',
properties: { required: ['model'],
model: { type: 'string' } properties: {
model: { type: 'string' }
}
} }
} }
}, async (request, reply) => { }, async (request, reply) => {
@@ -191,12 +541,11 @@ export async function registerOllamaRoutes(
const client = new OllamaCloudClient(config) const client = new OllamaCloudClient(config)
const { model } = request.body as any const { model } = request.body as any
// Start async pull operation
client.pullModel(model).catch(error => { client.pullModel(model).catch(error => {
logger.error({ error, model }, "Failed to pull model") logger.error({ error, model }, "Failed to pull model")
}) })
return { message: `Started pulling model: ${model}` } return { message: `Started pulling model: ${model}` }
} catch (error) { } catch (error) {
logger.error({ error }, "Failed to initiate model pull") logger.error({ error }, "Failed to initiate model pull")
@@ -207,18 +556,36 @@ export async function registerOllamaRoutes(
logger.info("Ollama Cloud routes registered") logger.info("Ollama Cloud routes registered")
} }
// Configuration management functions
function getOllamaConfig(): OllamaCloudConfig { function getOllamaConfig(): OllamaCloudConfig {
try { try {
const stored = localStorage.getItem('ollama_cloud_config') if (!fs.existsSync(CONFIG_FILE)) {
return stored ? JSON.parse(stored) : { enabled: false, endpoint: "https://ollama.com" } return { enabled: false, endpoint: "https://ollama.com" }
}
const data = fs.readFileSync(CONFIG_FILE, 'utf-8')
return JSON.parse(data)
} catch { } catch {
return { enabled: false, endpoint: "https://ollama.com" } return { enabled: false, endpoint: "https://ollama.com" }
} }
} }
function updateOllamaConfig(config: Partial<OllamaCloudConfig>): void { function updateOllamaConfig(config: Partial<OllamaCloudConfig>): void {
const current = getOllamaConfig() try {
const updated = { ...current, ...config } if (!fs.existsSync(CONFIG_DIR)) {
localStorage.setItem('ollama_cloud_config', JSON.stringify(updated)) 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 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( export async function registerQwenRoutes(
app: FastifyInstance, app: FastifyInstance,
deps: QwenRouteDeps deps: QwenRouteDeps
) { ) {
const logger = deps.logger.child({ component: "qwen-routes" }) const logger = deps.logger.child({ component: "qwen-routes" })
// Get OAuth URL for Qwen authentication // Qwen OAuth Device Flow: request device authorization
app.get('/api/qwen/oauth/url', async (request, reply) => { app.post('/api/qwen/oauth/device', {
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', {
schema: { schema: {
type: 'object', body: {
required: ['code', 'state'], type: 'object',
properties: { required: ['code_challenge', 'code_challenge_method'],
code: { type: 'string' }, properties: {
state: { type: 'string' }, code_challenge: { type: 'string' },
client_id: { type: 'string' }, code_challenge_method: { type: 'string' }
redirect_uri: { type: 'string' } }
} }
} }
}, async (request, reply) => { }, async (request, reply) => {
try { try {
const { code, state, client_id, redirect_uri } = request.body as any const { code_challenge, code_challenge_method } = request.body as any
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
// Exchange code for token with Qwen
const tokenResponse = await fetch('https://qwen.ai/oauth/token', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
}, },
body: new URLSearchParams({ body: new URLSearchParams({
grant_type: 'authorization_code', client_id: QWEN_OAUTH_CLIENT_ID,
client_id: client_id, scope: QWEN_OAUTH_SCOPE,
code, code_challenge,
redirect_uri: redirect_uri code_challenge_method
}) })
}) })
if (!tokenResponse.ok) { if (!response.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.statusText}`) 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 }
// Get user info } catch (error) {
const userResponse = await fetch('https://qwen.ai/api/user', { logger.error({ error }, "Failed to request Qwen device authorization")
headers: { return reply.status(500).send({ error: "Device authorization failed" })
'Authorization': `Bearer ${tokenData.access_token}` }
})
// 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: {
'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) { const responseText = await response.text()
throw new Error(`Failed to fetch user info: ${userResponse.statusText}`) if (!response.ok) {
logger.error({ status: response.status, responseText }, "Qwen device token poll failed")
return reply.status(response.status).send(responseText)
} }
try {
const userData = await userResponse.json() return reply.send(JSON.parse(responseText))
} catch {
return { return reply.send(responseText)
success: true,
user: userData,
token: {
access_token: tokenData.access_token,
token_type: tokenData.token_type,
expires_in: tokenData.expires_in,
scope: tokenData.scope
}
} }
} catch (error) { } catch (error) {
logger.error({ error }, "Qwen OAuth token exchange failed") logger.error({ error }, "Failed to poll Qwen token endpoint")
return reply.status(500).send({ error: "OAuth exchange failed" }) 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 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: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
@@ -126,9 +197,121 @@ export async function registerQwenRoutes(
} }
}) })
// 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" })
}
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") logger.info("Qwen OAuth routes registered")
} }
function generateState(): string {
return Math.random().toString(36).substring(2, 15) + Date.now().toString(36)
}

View File

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

View File

@@ -1,10 +1,18 @@
import { FastifyInstance, FastifyReply } from "fastify" import { FastifyInstance, FastifyReply } from "fastify"
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import { z } from "zod" 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 { WorkspaceManager } from "../../workspaces/manager"
import { InstanceStore } from "../../storage/instance-store"
import { ConfigStore } from "../../config/store"
import { getWorkspaceOpencodeConfigDir } from "../../opencode-config"
interface RouteDeps { interface RouteDeps {
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
instanceStore: InstanceStore
configStore: ConfigStore
} }
const WorkspaceCreateSchema = z.object({ const WorkspaceCreateSchema = z.object({
@@ -163,6 +171,143 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return { isRepo: true, branch, ahead, behind, changes } 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 { 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 { Logger } from "../../logger"
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
import { join } from "path" import { join } from "path"
import { homedir } from "os" import { getUserIntegrationsDir } from "../../user-data"
interface ZAIRouteDeps { interface ZAIRouteDeps {
logger: Logger logger: Logger
} }
// Config file path const CONFIG_DIR = getUserIntegrationsDir()
const CONFIG_DIR = join(homedir(), ".nomadarch")
const CONFIG_FILE = join(CONFIG_DIR, "zai-config.json") const CONFIG_FILE = join(CONFIG_DIR, "zai-config.json")
export async function registerZAIRoutes( export async function registerZAIRoutes(
@@ -69,15 +68,7 @@ export async function registerZAIRoutes(
// List available models // List available models
app.get('/api/zai/models', async (request, reply) => { app.get('/api/zai/models', async (request, reply) => {
try { try {
const config = getZAIConfig() return { models: ZAI_MODELS.map(name => ({ name, provider: "zai" })) }
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" })) }
} catch (error) { } catch (error) {
logger.error({ error }, "Failed to list Z.AI models") logger.error({ error }, "Failed to list Z.AI models")
return reply.status(500).send({ error: "Failed to list 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)) { for await (const chunk of client.chatStream(chatRequest)) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`) reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
// Check for message_stop event // Check for finish_reason to end stream
if (chunk.type === "message_stop") { const finishReason = chunk.choices[0]?.finish_reason
if (finishReason) {
reply.raw.write('data: [DONE]\n\n') reply.raw.write('data: [DONE]\n\n')
break break
} }
@@ -133,16 +125,15 @@ export async function registerZAIRoutes(
logger.info("Z.AI routes registered") logger.info("Z.AI routes registered")
} }
// Configuration management functions using file-based storage
function getZAIConfig(): ZAIConfig { function getZAIConfig(): ZAIConfig {
try { try {
if (existsSync(CONFIG_FILE)) { if (existsSync(CONFIG_FILE)) {
const data = readFileSync(CONFIG_FILE, 'utf-8') const data = readFileSync(CONFIG_FILE, 'utf-8')
return JSON.parse(data) 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 { } 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 fs from "fs"
import { promises as fsp } from "fs" import { promises as fsp } from "fs"
import os from "os"
import path from "path" import path from "path"
import type { InstanceData } from "../api-types" import type { InstanceData } from "../api-types"
import { getUserInstancesDir } from "../user-data"
const DEFAULT_INSTANCE_DATA: InstanceData = { const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [], messageHistory: [],
@@ -13,7 +13,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
export class InstanceStore { export class InstanceStore {
private readonly instancesDir: string private readonly instancesDir: string
constructor(baseDir = path.join(os.homedir(), ".config", "codenomad", "instances")) { constructor(baseDir = getUserInstancesDir()) {
this.instancesDir = baseDir this.instancesDir = baseDir
fs.mkdirSync(this.instancesDir, { recursive: true }) 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 { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger" import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config" import { ensureWorkspaceOpencodeConfig } from "../opencode-config"
const STARTUP_STABILITY_DELAY_MS = 1500 const STARTUP_STABILITY_DELAY_MS = 1500
@@ -27,11 +27,9 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager { export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>() private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
constructor(private readonly options: WorkspaceManagerOptions) { constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger) this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
} }
list(): WorkspaceDescriptor[] { list(): WorkspaceDescriptor[] {
@@ -105,9 +103,10 @@ export class WorkspaceManager {
const preferences = this.options.configStore.get().preferences ?? {} const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {} const userEnvironment = preferences.environmentVariables ?? {}
const opencodeConfigDir = ensureWorkspaceOpencodeConfig(id)
const environment = { const environment = {
...userEnvironment, ...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir, OPENCODE_CONFIG_DIR: opencodeConfigDir,
} }
try { try {

View File

@@ -7,7 +7,8 @@
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
@@ -30,8 +31,10 @@
"autoprefixer": "10.4.21", "autoprefixer": "10.4.21",
"postcss": "8.5.6", "postcss": "8.5.6",
"tailwindcss": "3", "tailwindcss": "3",
"tsx": "^4.21.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.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, setIsSelectingFolder,
showFolderSelection, showFolderSelection,
setShowFolderSelection, setShowFolderSelection,
showFolderSelectionOnStart,
setShowFolderSelectionOnStart,
} from "./stores/ui" } from "./stores/ui"
import { useConfig } from "./stores/preferences" import { useConfig } from "./stores/preferences"
import { import {
@@ -74,6 +76,8 @@ const App: Component = () => {
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const shouldShowFolderSelection = () => !hasInstances() || showFolderSelectionOnStart()
const updateInstanceTabBarHeight = () => { const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance") const element = document.querySelector<HTMLElement>(".tab-bar-instance")
@@ -156,6 +160,7 @@ const App: Component = () => {
clearLaunchError() clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary) const instanceId = await createInstance(folderPath, selectedBinary)
setShowFolderSelection(false) setShowFolderSelection(false)
setShowFolderSelectionOnStart(false)
setIsAdvancedSettingsOpen(false) setIsAdvancedSettingsOpen(false)
log.info("Created instance", { log.info("Created instance", {
@@ -375,7 +380,7 @@ const App: Component = () => {
</Dialog> </Dialog>
<div class="h-screen w-screen flex flex-col"> <div class="h-screen w-screen flex flex-col">
<Show <Show
when={!hasInstances()} when={shouldShowFolderSelection()}
fallback={ fallback={
<> <>
<InstanceTabs <InstanceTabs
@@ -432,6 +437,7 @@ const App: Component = () => {
<button <button
onClick={() => { onClick={() => {
setShowFolderSelection(false) setShowFolderSelection(false)
setShowFolderSelectionOnStart(false)
setIsAdvancedSettingsOpen(false) setIsAdvancedSettingsOpen(false)
clearLaunchError() 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 { sessions, withSession, setActiveSession } from "@/stores/session-state";
import { instances } from "@/stores/instances"; import { instances } from "@/stores/instances";
import { sendMessage } from "@/stores/session-actions"; import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession } from "@/stores/session-actions";
import { addTask, setActiveTask } from "@/stores/task-actions"; import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions";
import { messageStoreBus } from "@/stores/message-v2/bus"; 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 { formatTokenTotal } from "@/lib/formatters";
import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval, toggleApex } from "@/stores/solo-store"; import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval, toggleApex } from "@/stores/solo-store";
import { getLogger } from "@/lib/logger"; import { getLogger } from "@/lib/logger";
import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction";
import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events";
import { import {
Command, Command,
Plus, Plus,
@@ -35,10 +37,18 @@ import {
User, User,
Settings, Settings,
Key, Key,
FileArchive,
Paperclip,
} from "lucide-solid"; } 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 { InstanceMessageStore } from "@/stores/message-v2/instance-store";
import type { Task } from "@/types/session"; import type { Task } from "@/types/session";
const OPEN_ADVANCED_SETTINGS_EVENT = "open-advanced-settings";
const log = getLogger("multix-chat"); const log = getLogger("multix-chat");
interface MultiTaskChatProps { 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 setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
const [isSending, setIsSending] = createSignal(false); const [isSending, setIsSending] = createSignal(false);
const [chatInput, setChatInput] = createSignal(""); const [chatInput, setChatInput] = createSignal("");
const [isCompacting, setIsCompacting] = createSignal(false);
const [attachments, setAttachments] = createSignal<ReturnType<typeof createFileAttachment>[]>([]);
let scrollContainer: HTMLDivElement | undefined; let scrollContainer: HTMLDivElement | undefined;
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null); 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 // Scroll to bottom helper
const scrollToBottom = () => { const scrollToBottom = () => {
if (scrollContainer) { if (scrollContainer && !userScrolling()) {
scrollContainer.scrollTop = scrollContainer.scrollHeight; 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 // Get current session and tasks
const session = () => { const session = () => {
const instanceSessions = sessions().get(props.instanceId); const instanceSessions = sessions().get(props.instanceId);
@@ -69,7 +91,8 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
}; };
const tasks = () => session()?.tasks || []; 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 // Message store integration
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId); const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
@@ -114,19 +137,20 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
return { return {
used: usage?.actualUsageTokens ?? 0, used: usage?.actualUsageTokens ?? 0,
total: usage?.totalCost ?? 0, total: usage?.totalCost ?? 0,
input: usage?.inputTokens ?? 0, // input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0, // output: usage?.outputTokens ?? 0,
reasoning: usage?.reasoningTokens ?? 0, // reasoning: usage?.reasoningTokens ?? 0,
cacheRead: usage?.cacheReadTokens ?? 0, // cacheRead: usage?.cacheReadTokens ?? 0,
cacheWrite: usage?.cacheWriteTokens ?? 0, // cacheWrite: usage?.cacheWriteTokens ?? 0,
cost: usage?.totalCost ?? 0, cost: usage?.totalCost ?? 0,
}; };
}); });
// Get current model from instance // Get current model from active task session
const currentModel = createMemo(() => { const currentModel = createMemo(() => {
const instance = instances().get(props.instanceId); const instanceSessions = sessions().get(props.instanceId);
return instance?.modelId || "unknown"; const session = instanceSessions?.get(activeTaskSessionId());
return session?.model?.modelId || "unknown";
}); });
const activeTaskSessionId = createMemo(() => { const activeTaskSessionId = createMemo(() => {
@@ -134,6 +158,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
return task?.taskSessionId || props.sessionId; 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); const solo = () => getSoloState(props.instanceId);
// APEX PRO mode = SOLO + APEX combined (autonomous + auto-approval) // APEX PRO mode = SOLO + APEX combined (autonomous + auto-approval)
@@ -181,8 +220,12 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const streaming = isAgentThinking(); const streaming = isAgentThinking();
if (!streaming) return; if (!streaming) return;
// During streaming, scroll periodically to keep up with content // During streaming, scroll periodically to keep up with content (unless user is scrolling)
const interval = setInterval(scrollToBottom, 300); const interval = setInterval(() => {
if (!userScrolling()) {
scrollToBottom();
}
}, 300);
return () => clearInterval(interval); return () => clearInterval(interval);
}); });
@@ -191,14 +234,40 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const ids = filteredMessageIds(); const ids = filteredMessageIds();
const thinking = isAgentThinking(); const thinking = isAgentThinking();
// Scroll when message count changes or when thinking starts // Scroll when message count changes or when thinking starts (unless user is scrolling)
if (ids.length > 0 || thinking) { if ((ids.length > 0 || thinking) && !userScrolling()) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTimeout(scrollToBottom, 50); 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 handleSendMessage = async () => {
const message = chatInput().trim(); const message = chatInput().trim();
if (!message || isSending()) return; if (!message || isSending()) return;
@@ -253,12 +322,13 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
props.instanceId, props.instanceId,
targetSessionId, targetSessionId,
message, message,
[], attachments(),
taskId || undefined taskId || undefined
); );
log.info("sendMessage call completed"); log.info("sendMessage call completed");
setChatInput(""); setChatInput("");
setAttachments([]);
// Auto-scroll to bottom after sending // Auto-scroll to bottom after sending
setTimeout(scrollToBottom, 100); 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) => { const handleKeyDown = (e: KeyboardEvent) => {
// Enter to submit, Shift+Enter for new line // Enter to submit, Shift+Enter for new line
if (e.key === "Enter" && !e.shiftKey) { 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 ( 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 */}
<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"> <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"> <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" /> <Zap size={10} class="text-white fill-current" />
</div> </div>
</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()}> <Show when={selectedTaskId()}>
<div class="flex items-center space-x-2 animate-in fade-in slide-in-from-left-2 duration-300"> <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> </div>
</Show> </Show>
{/* API Key Manager Button */} {/* Compact Button - Context Compression & Summary */}
<button <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" 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" title="API Key Manager"
> >
@@ -369,7 +534,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</header> </header>
{/* Task Tabs (Horizontal Scroll) */} {/* 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"> <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 <button
onClick={() => setSelectedTaskId(null)} 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="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"> <div class="flex items-center space-x-1.5 overflow-x-auto no-scrollbar">
<For each={tasks()}> <For each={visibleTasks()}>
{(task) => ( {(task) => (
<button <button
onClick={() => setSelectedTaskId(task.id)} 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" "bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.4)] animate-pulse"
}`} /> }`} />
<span class="truncate">{task.title}</span> <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}> <Show when={selectedTaskId() === task.id}>
<div class="ml-1 w-1 h-1 bg-indigo-400 rounded-full animate-ping" /> <div class="ml-1 w-1 h-1 bg-indigo-400 rounded-full animate-ping" />
</Show> </Show>
@@ -409,8 +586,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<button <button
onClick={() => { onClick={() => {
setChatInput(""); handleCreateTask();
setSelectedTaskId(null);
}} }}
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" 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" title="New Task"
@@ -420,6 +596,25 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</div> </div>
</Show> </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 */} {/* Main Content Area - min-h-0 is critical for flex containers with overflow */}
<div class="flex-1 min-h-0 relative overflow-hidden flex"> <div class="flex-1 min-h-0 relative overflow-hidden flex">
{/* Main chat area */} {/* Main chat area */}
@@ -428,6 +623,18 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
ref={scrollContainer} ref={scrollContainer}
class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar" 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={ <Show when={!selectedTaskId()} fallback={
<div class="p-3 pb-4 overflow-x-hidden"> <div class="p-3 pb-4 overflow-x-hidden">
<MessageBlockList <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> <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" /> <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"> <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> </span>
</div> </div>
<div class="grid gap-3"> <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="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"> <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} /> <Plus size={24} strokeWidth={1.5} />
@@ -491,7 +698,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span>{task.messageIds?.length || 0} messages</span> <span>{task.messageIds?.length || 0} messages</span>
</div> </div>
</div> </div>
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" /> <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> </button>
)} )}
</For> </For>
@@ -572,20 +793,22 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "THINKING" : "SENDING"}</span> <span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "THINKING" : "SENDING"}</span>
</div> </div>
</Show> </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>
</Show>
</div> </div>
</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>
{/* Text Input */} {/* Text Input */}
<textarea <textarea
value={chatInput()} value={chatInput()}
@@ -601,49 +824,32 @@ 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 justify-between pt-2 border-t border-white/5 mt-2">
<div class="flex items-center space-x-2 flex-wrap gap-y-1"> <div class="flex items-center space-x-2 flex-wrap gap-y-1">
{/* Detailed token stats */} {/* Detailed token stats */}
<Show when={tokenStats().input > 0 || tokenStats().output > 0}> {/* Detailed breakdown not available */}
<div class="flex items-center space-x-1.5"> <div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">INPUT</span> <span class="text-[8px] font-bold text-zinc-600 uppercase">COST</span>
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().input.toLocaleString()}</span> <span class="text-[9px] font-bold text-violet-400">${tokenStats().cost.toFixed(4)}</span>
</div> </div>
<div class="w-px h-3 bg-zinc-800" /> <div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5"> <div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">OUTPUT</span> <span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().output.toLocaleString()}</span> <span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
</div> </div>
<Show when={tokenStats().reasoning > 0}> <div class="flex items-center space-x-1.5">
<div class="w-px h-3 bg-zinc-800" /> <input
<div class="flex items-center space-x-1.5"> ref={fileInputRef}
<span class="text-[8px] font-bold text-zinc-600 uppercase">REASONING</span> type="file"
<span class="text-[9px] font-bold text-amber-400">{tokenStats().reasoning.toLocaleString()}</span> multiple
</div> class="sr-only"
</Show> onChange={handleFileSelect}
<Show when={tokenStats().cacheRead > 0}> />
<div class="w-px h-3 bg-zinc-800" /> <button
<div class="flex items-center space-x-1.5"> type="button"
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE READ</span> onClick={() => fileInputRef?.click()}
<span class="text-[9px] font-bold text-emerald-400">{tokenStats().cacheRead.toLocaleString()}</span> class="text-zinc-600 hover:text-indigo-300 transition-colors p-1"
</div> title="Attach files"
</Show> >
<Show when={tokenStats().cacheWrite > 0}> <Paperclip size={14} />
<div class="w-px h-3 bg-zinc-800" /> </button>
<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" />
<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>
</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">MODEL</span>
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
</div>
</Show>
<Show when={!(tokenStats().input > 0 || tokenStats().output > 0)}>
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1"> <button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
<Hash size={14} /> <Hash size={14} />
</button> </button>
@@ -655,23 +861,35 @@ 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> <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> <span class="text-[9px]">to send</span>
</div> </div>
</Show> </div>
</div> </div>
<button <div class="flex items-center space-x-2">
onClick={handleSendMessage} <Show when={isAgentThinking() || isSending()}>
disabled={!chatInput().trim() || isSending()} <button
class="px-4 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-[11px] font-bold uppercase tracking-wide transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center space-x-1.5" 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"
<Show when={isSending()} fallback={ title="Stop response"
<> >
<span>{selectedTaskId() ? "Update Task" : "Launch Task"}</span> <StopCircle size={12} class="inline-block mr-1" />
<ArrowUp size={12} strokeWidth={3} /> Stop
</> </button>
}>
<Loader2 size={12} class="animate-spin" />
</Show> </Show>
</button> <button
onClick={handleSendMessage}
disabled={!chatInput().trim() || isSending()}
class="px-4 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-[11px] font-bold uppercase tracking-wide transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center space-x-1.5"
>
<Show when={isSending()} fallback={
<>
<span>{selectedTaskId() ? "Update Task" : "Launch Task"}</span>
<ArrowUp size={12} strokeWidth={3} />
</>
}>
<Loader2 size={12} class="animate-spin" />
</Show>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -679,30 +897,37 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
{/* Message Navigation Sidebar - YOU/ASST labels with hover preview */} {/* Message Navigation Sidebar - YOU/ASST labels with hover preview */}
<Show when={selectedTaskId() && filteredMessageIds().length > 0}> <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()}> <For each={filteredMessageIds()}>
{(messageId, index) => { {(messageId, index) => {
const msg = () => messageStore().getMessage(messageId); const msg = () => messageStore().getMessage(messageId);
const isUser = () => msg()?.role === "user"; const isUser = () => msg()?.role === "user";
const [showPreview, setShowPreview] = createSignal(false); const [showPreview, setShowPreview] = createSignal(false);
// Get message preview text (first 100 chars) // Get message preview text (first 150 chars)
const previewText = () => { const previewText = () => {
const message = msg(); const message = msg();
if (!message) return ""; 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); 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 ( return (
<div class="relative group"> <div class="relative group">
<button <button
onClick={() => { onClick={handleTabClick}
// Scroll to message
const element = document.getElementById(`msg-${messageId}`);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
}}
onMouseEnter={() => setShowPreview(true)} onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)} onMouseLeave={() => setShowPreview(false)}
class={`w-10 py-1.5 rounded text-[8px] font-black uppercase transition-all cursor-pointer ${isUser() 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 */} {/* Hover Preview Tooltip */}
<Show when={showPreview()}> <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="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={`text-[9px] font-bold uppercase mb-1 ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}> <div class="flex items-center justify-between mb-2">
{isUser() ? "You" : "Assistant"} Message {index() + 1} <div class={`text-[9px] font-bold uppercase ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
{isUser() ? "You" : "Assistant"} Msg {index() + 1}
</div>
<div class="text-[8px] text-zinc-600">
{msg()?.status === "streaming" ? "• Streaming" : ""}
</div>
</div> </div>
<p class="text-[11px] text-zinc-300 leading-relaxed line-clamp-4"> <p class="text-[10px] text-zinc-300 leading-relaxed whitespace-pre-wrap">
{previewText()} {previewText()}
</p> </p>
</div> </div>
@@ -732,79 +962,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</Show> </Show>
</div> </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]"> </main >
{/* 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 DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions" 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 { interface FolderSelectionViewProps {
@@ -24,9 +25,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) 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() const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders() const folders = () => recentFolders()
const isLoading = () => Boolean(props.isLoading) const isLoading = () => Boolean(props.isLoading)
@@ -153,6 +160,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onMount(() => { onMount(() => {
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown)
refreshUsers()
onCleanup(() => { onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown) window.removeEventListener("keydown", handleKeyDown)
}) })
@@ -174,7 +182,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isLoading()) return if (isLoading()) return
props.onSelectFolder(path, selectedBinary()) props.onSelectFolder(path, selectedBinary())
} }
async function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
setFocusMode("new") setFocusMode("new")
@@ -191,17 +199,48 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} }
setIsFolderBrowserOpen(true) setIsFolderBrowserOpen(true)
} }
function handleBrowserSelect(path: string) { function handleBrowserSelect(path: string) {
setIsFolderBrowserOpen(false) setIsFolderBrowserOpen(false)
handleFolderSelect(path) handleFolderSelect(path)
} }
function handleBinaryChange(binary: string) { function handleBinaryChange(binary: string) {
setSelectedBinary(binary) 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) { function handleRemove(path: string, e?: Event) {
if (isLoading()) return if (isLoading()) return
e?.stopPropagation() 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" 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"} 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}> <Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6"> <div class="absolute top-4 right-6">
<button <button
@@ -242,15 +290,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</button> </button>
</div> </div>
</Show> </Show>
<div class="mb-6 text-center shrink-0"> <div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center"> <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">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> </div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
</div>
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col"> <div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show <Show
@@ -419,6 +475,104 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClose={() => setIsFolderBrowserOpen(false)} onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect} 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 type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status" 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 { interface InstanceInfoProps {
instance: Instance instance: Instance
@@ -22,6 +25,68 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const env = environmentVariables() const env = environmentVariables()
return env ? Object.entries(env) : [] 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 ( return (
<div class="panel"> <div class="panel">
@@ -116,6 +181,39 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" /> <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()}> <Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1"> <div class="text-xs text-muted py-1">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@@ -155,6 +253,37 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div> </div>
</div> </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> </div>
) )
} }

View File

@@ -66,6 +66,7 @@ import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager" import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import AdvancedSettingsModal from "../advanced-settings-modal" import AdvancedSettingsModal from "../advanced-settings-modal"
import { showConfirmDialog } from "../../stores/alerts"
import { import {
getSoloState, getSoloState,
toggleAutonomous, 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 RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-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 [terminalOpen, setTerminalOpen] = createSignal(false)
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp", "plan"]) const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp", "plan"])
const [currentFile, setCurrentFile] = createSignal<FileNode | null>(null) 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 [isSoloOpen, setIsSoloOpen] = createSignal(true)
const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false) const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false)
const [selectedBinary, setSelectedBinary] = createSignal("opencode") const [selectedBinary, setSelectedBinary] = createSignal("opencode")
@@ -284,6 +288,25 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCleanup(() => window.removeEventListener("open-advanced-settings", handler)) 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(() => { createEffect(() => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString()) window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString())
@@ -449,6 +472,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
let sidebarActionId = 0 let sidebarActionId = 0
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null) 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 }) => { const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
target.dispatchEvent( target.dispatchEvent(
@@ -499,6 +523,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const handleSidebarRequest = (action: SessionSidebarRequestAction) => { const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
setPendingSidebarAction({ action, id: sidebarActionId++ }) setPendingSidebarAction({ action, id: sidebarActionId++ })
if (action === "show-skills") {
setSidebarRequestedTab("skills")
}
if (!leftPinned() && !leftOpen()) { if (!leftPinned() && !leftOpen()) {
setLeftOpen(true) setLeftOpen(true)
measureDrawerHost() measureDrawerHost()
@@ -902,6 +929,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onToggleTerminal={() => setTerminalOpen((current) => !current)} onToggleTerminal={() => setTerminalOpen((current) => !current)}
isTerminalOpen={terminalOpen()} isTerminalOpen={terminalOpen()}
onOpenAdvancedSettings={() => setShowAdvancedSettings(true)} onOpenAdvancedSettings={() => setShowAdvancedSettings(true)}
requestedTab={sidebarRequestedTab()}
/> />
) )
@@ -1243,18 +1271,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button> </button>
</div> </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"> <div class="flex items-center bg-white/5 border border-white/5 rounded-full px-1.5 py-1 space-x-1">
<button <button
onClick={() => toggleAutonomous(props.instance.id)} 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 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" ? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
: "text-zinc-500 hover:text-zinc-300" : "text-zinc-500 hover:text-zinc-300"
}`} }`}
> >
<Zap size={12} class={getSoloState(props.instance.id).isAutonomous ? "animate-pulse" : ""} /> <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>
<button <button
onClick={() => toggleAutoApproval(props.instance.id)} onClick={() => toggleAutoApproval(props.instance.id)}
@@ -1305,7 +1333,65 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
> >
<div class="flex-1 flex overflow-hidden min-h-0"> <div class="flex-1 flex overflow-hidden min-h-0">
<Show when={!isPhoneLayout()}> <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> </Show>
<div <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 { import {
Files, Files,
Search, Search,
@@ -18,6 +18,7 @@ import {
} from "lucide-solid" } from "lucide-solid"
import { serverApi } from "../../lib/api-client" import { serverApi } from "../../lib/api-client"
import InstanceServiceStatus from "../instance-service-status" import InstanceServiceStatus from "../instance-service-status"
import McpManager from "../mcp-manager"
import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills" import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills"
import { getSessionSkills, setSessionSkills } from "../../stores/session-state" import { getSessionSkills, setSessionSkills } from "../../stores/session-state"
@@ -41,6 +42,7 @@ interface SidebarProps {
onToggleTerminal?: () => void onToggleTerminal?: () => void
isTerminalOpen?: boolean isTerminalOpen?: boolean
onOpenAdvancedSettings?: () => void onOpenAdvancedSettings?: () => void
requestedTab?: string | null
} }
const getFileIcon = (fileName: string) => { const getFileIcon = (fileName: string) => {
@@ -128,6 +130,7 @@ const FileTree: Component<{
export const Sidebar: Component<SidebarProps> = (props) => { export const Sidebar: Component<SidebarProps> = (props) => {
const [activeTab, setActiveTab] = createSignal("files") const [activeTab, setActiveTab] = createSignal("files")
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([]) const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
const [lastRequestedTab, setLastRequestedTab] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("") const [searchQuery, setSearchQuery] = createSignal("")
const [searchResults, setSearchResults] = createSignal<FileNode[]>([]) const [searchResults, setSearchResults] = createSignal<FileNode[]>([])
const [searchLoading, setSearchLoading] = createSignal(false) const [searchLoading, setSearchLoading] = createSignal(false)
@@ -141,20 +144,40 @@ export const Sidebar: Component<SidebarProps> = (props) => {
} | null>(null) } | null>(null)
const [gitLoading, setGitLoading] = createSignal(false) const [gitLoading, setGitLoading] = createSignal(false)
const [skillsFilter, setSkillsFilter] = createSignal("") const [skillsFilter, setSkillsFilter] = createSignal("")
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
createEffect(async () => { const openExternal = (url: string) => {
if (props.instanceId) { if (typeof window === "undefined") return
try { window.open(url, "_blank", "noopener,noreferrer")
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".") }
setRootFiles(entries.map(e => ({
name: e.name, const refreshRootFiles = async () => {
type: e.type, if (!props.instanceId) return
path: e.path try {
}))) const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
} catch (e) { setRootFiles(entries.map(e => ({
console.error("Failed to load root files", e) name: e.name,
} type: e.type,
path: e.path
})))
} catch (e) {
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(() => { 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 filteredSkills = createMemo(() => {
const term = skillsFilter().trim().toLowerCase() const term = skillsFilter().trim().toLowerCase()
if (!term) return catalog() if (!term) return catalog()
@@ -410,10 +440,7 @@ export const Sidebar: Component<SidebarProps> = (props) => {
</div> </div>
</Show> </Show>
<Show when={activeTab() === "mcp"}> <Show when={activeTab() === "mcp"}>
<div class="flex flex-col gap-3"> <McpManager instanceId={props.instanceId} />
<div class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</div>
<InstanceServiceStatus sections={["mcp"]} />
</div>
</Show> </Show>
<Show when={activeTab() === "skills"}> <Show when={activeTab() === "skills"}>
<div class="flex flex-col gap-3"> <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 type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" 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" import MessagePart from "./message-part"
interface MessageItemProps { interface MessageItemProps {
@@ -125,6 +127,27 @@ interface MessageItemProps {
return null 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 = () => { const hasContent = () => {
if (errorMessage() !== null) { if (errorMessage() !== null) {
return true return true
@@ -138,6 +161,19 @@ interface MessageItemProps {
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0 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 = () => { const handleRevert = () => {
if (props.onRevert && isUser()) { if (props.onRevert && isUser()) {
props.onRevert(props.record.id) props.onRevert(props.record.id)
@@ -185,7 +221,7 @@ interface MessageItemProps {
const modelID = info.modelID || "" const modelID = info.modelID || ""
const providerID = info.providerID || "" const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}` if (modelID && providerID) return `${providerID}/${modelID}`
return modelID return modelID || "unknown"
} }
const agentMeta = () => { const agentMeta = () => {
@@ -202,6 +238,20 @@ interface MessageItemProps {
return segments.join(" • ") 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 ( return (
<div class={containerClass()}> <div class={containerClass()}>
@@ -259,6 +309,11 @@ interface MessageItemProps {
</Show> </Show>
</button> </button>
</Show> </Show>
<Show when={modelBadge()}>
{(badge) => (
<span class="ml-2">{badge()}</span>
)}
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time> <time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div> </div>
@@ -266,13 +321,45 @@ interface MessageItemProps {
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]"> <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()}> <Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div> <div class="message-queued-badge">QUEUED</div>
</Show> </Show>
<Show when={errorMessage()}> <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>
<Show when={isGenerating()}> <Show when={isGenerating()}>

View File

@@ -7,6 +7,9 @@ import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" 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_SCOPE = "session"
const SCROLL_SENTINEL_MARGIN_PX = 48 const SCROLL_SENTINEL_MARGIN_PX = 48
@@ -51,6 +54,10 @@ export default function MessageSection(props: MessageSectionProps) {
contextAvailableTokens: null, contextAvailableTokens: null,
}, },
) )
const isCompacting = createMemo(() => getSessionStatus(props.instanceId, props.sessionId) === "compacting")
const compactionSuggestion = createMemo(() =>
getCompactionSuggestion(props.instanceId, props.sessionId),
)
const tokenStats = createMemo(() => { const tokenStats = createMemo(() => {
const usage = usageSnapshot() const usage = usageSnapshot()
@@ -747,6 +754,30 @@ export default function MessageSection(props: MessageSectionProps) {
<div class="message-stream-container"> <div class="message-stream-container">
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}> <div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
<div class="message-stream-shell" ref={setShellElement}> <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 class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} /> <div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
<Show when={!props.loading && messageIds().length === 0}> <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 { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session" import type { Model } from "../types/session"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("session") const log = getLogger("session")
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models" const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
@@ -40,7 +41,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
const readOfflineModels = () => { const readOfflineModels = () => {
if (typeof window === "undefined") return new Set<string>() if (typeof window === "undefined") return new Set<string>()
try { 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) : [] const parsed = raw ? JSON.parse(raw) : []
return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : []) return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : [])
} catch { } catch {
@@ -57,7 +58,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
if (typeof window === "undefined") return if (typeof window === "undefined") return
const handleCustom = () => refreshOfflineModels() const handleCustom = () => refreshOfflineModels()
const handleStorage = (event: StorageEvent) => { const handleStorage = (event: StorageEvent) => {
if (event.key === OPENCODE_ZEN_OFFLINE_STORAGE_KEY) { if (event.key === getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)) {
refreshOfflineModels() refreshOfflineModels()
} }
} }

View File

@@ -1169,6 +1169,12 @@ export default function PromptInput(props: PromptInputProps) {
</div> </div>
<div class="prompt-input-actions"> <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 <button
type="button" type="button"
class="stop-button" class="stop-button"

View File

@@ -2,6 +2,8 @@ import { Component, createSignal, onMount, Show } from 'solid-js'
import toast from 'solid-toast' import toast from 'solid-toast'
import { Button } from '@suid/material' import { Button } from '@suid/material'
import { Cloud, CheckCircle, XCircle, Loader } from 'lucide-solid' import { Cloud, CheckCircle, XCircle, Loader } from 'lucide-solid'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
interface OllamaCloudConfig { interface OllamaCloudConfig {
enabled: boolean enabled: boolean
@@ -12,9 +14,11 @@ interface OllamaCloudConfig {
interface OllamaCloudModelsResponse { interface OllamaCloudModelsResponse {
models: Array<{ models: Array<{
name: string name: string
size: string model?: string
digest: string size?: string | number
modified_at: string digest?: string
modified_at?: string
details?: any
}> }>
} }
@@ -25,14 +29,20 @@ const OllamaCloudSettings: Component = () => {
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle') const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
const [models, setModels] = createSignal<string[]>([]) const [models, setModels] = createSignal<string[]>([])
const [isLoadingModels, setIsLoadingModels] = createSignal(false) const [isLoadingModels, setIsLoadingModels] = createSignal(false)
const [hasStoredApiKey, setHasStoredApiKey] = createSignal(false)
// Load config on mount // Load config on mount
onMount(async () => { onMount(async () => {
try { try {
const response = await fetch('http://localhost:6149/api/ollama/config') const response = await fetch('/api/ollama/config')
if (response.ok) { if (response.ok) {
const data = await response.json() 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) { } catch (error) {
console.error('Failed to load Ollama config:', error) console.error('Failed to load Ollama config:', error)
@@ -47,10 +57,15 @@ const OllamaCloudSettings: Component = () => {
const saveConfig = async () => { const saveConfig = async () => {
setIsLoading(true) setIsLoading(true)
try { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config()) body: JSON.stringify(payload)
}) })
if (response.ok) { if (response.ok) {
@@ -58,6 +73,16 @@ const OllamaCloudSettings: Component = () => {
duration: 3000, duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" /> 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 { } else {
throw new Error('Failed to save config') throw new Error('Failed to save config')
} }
@@ -74,22 +99,22 @@ const OllamaCloudSettings: Component = () => {
const testConnection = async () => { const testConnection = async () => {
setIsTesting(true) setIsTesting(true)
setConnectionStatus('testing') setConnectionStatus('testing')
try { try {
const response = await fetch('http://localhost:6149/api/ollama/test', { const response = await fetch('/api/ollama/test', {
method: 'POST' method: 'POST'
}) })
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
setConnectionStatus(data.connected ? 'connected' : 'failed') setConnectionStatus(data.connected ? 'connected' : 'failed')
if (data.connected) { if (data.connected) {
toast.success('Successfully connected to Ollama Cloud', { toast.success('Successfully connected to Ollama Cloud', {
duration: 3000, duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" /> icon: <CheckCircle class="w-4 h-4 text-green-500" />
}) })
// Load models after successful connection // Load models after successful connection
loadModels() loadModels()
} else { } else {
@@ -115,13 +140,32 @@ const OllamaCloudSettings: Component = () => {
const loadModels = async () => { const loadModels = async () => {
setIsLoadingModels(true) setIsLoadingModels(true)
try { try {
const response = await fetch('http://localhost:6149/api/ollama/models/cloud') const response = await fetch('/api/ollama/models')
if (response.ok) { if (response.ok) {
const data: OllamaCloudModelsResponse = await response.json() const data = await response.json()
setModels(data.models.map(model => model.name)) // 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) { } catch (error) {
console.error('Failed to load models:', 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 { } finally {
setIsLoadingModels(false) setIsLoadingModels(false)
} }
@@ -164,12 +208,13 @@ const OllamaCloudSettings: Component = () => {
<label class="block font-medium mb-2">API Key</label> <label class="block font-medium mb-2">API Key</label>
<input <input
type="password" 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 || ''} value={config().apiKey || ''}
onChange={(e) => handleConfigChange('apiKey', e.target.value)} 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" 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} 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> </div>
{/* Endpoint */} {/* 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" 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} 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> </div>
{/* Test Connection */} {/* Test Connection */}
@@ -196,7 +242,7 @@ const OllamaCloudSettings: Component = () => {
{getStatusIcon()} {getStatusIcon()}
{isTesting() ? 'Testing...' : 'Test Connection'} {isTesting() ? 'Testing...' : 'Test Connection'}
</Button> </Button>
<Show when={connectionStatus() === 'connected'}> <Show when={connectionStatus() === 'connected'}>
<span class="text-green-600 text-sm">Connected successfully</span> <span class="text-green-600 text-sm">Connected successfully</span>
</Show> </Show>
@@ -208,8 +254,8 @@ const OllamaCloudSettings: Component = () => {
{/* Available Models */} {/* Available Models */}
<Show when={models().length > 0}> <Show when={models().length > 0}>
<div> <div>
<label class="block font-medium mb-2">Available Cloud Models</label> <label class="block font-medium mb-2">Available Models</label>
<div class="grid grid-cols-1 gap-2"> <div class="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
{models().map(model => ( {models().map(model => (
<div class="p-3 border border-gray-200 rounded-md bg-gray-50"> <div class="p-3 border border-gray-200 rounded-md bg-gray-50">
<code class="text-sm font-mono">{model}</code> <code class="text-sm font-mono">{model}</code>
@@ -236,4 +282,4 @@ const OllamaCloudSettings: Component = () => {
) )
} }
export default OllamaCloudSettings export default OllamaCloudSettings

View File

@@ -3,6 +3,8 @@ import toast from 'solid-toast'
import { Button } from '@suid/material' import { Button } from '@suid/material'
import { User, CheckCircle, XCircle, Loader, LogOut, ExternalLink } from 'lucide-solid' import { User, CheckCircle, XCircle, Loader, LogOut, ExternalLink } from 'lucide-solid'
import { useQwenOAuth } from '../../lib/integrations/qwen-oauth' import { useQwenOAuth } from '../../lib/integrations/qwen-oauth'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
interface QwenUser { interface QwenUser {
id: string id: string
@@ -17,7 +19,7 @@ interface QwenUser {
} }
const QwenCodeSettings: Component = () => { 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 [isSigningOut, setIsSigningOut] = createSignal(false)
const handleSignIn = async () => { const handleSignIn = async () => {
@@ -27,6 +29,13 @@ const QwenCodeSettings: Component = () => {
duration: 3000, duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" /> 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) { } catch (error) {
toast.error('Failed to authenticate with Qwen Code', { toast.error('Failed to authenticate with Qwen Code', {
duration: 5000, duration: 5000,
@@ -59,6 +68,32 @@ const QwenCodeSettings: Component = () => {
return `${user.limits.requests_per_day} requests/day, ${user.limits.requests_per_minute}/min` 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 ( return (
<div class="space-y-6 p-6"> <div class="space-y-6 p-6">
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
@@ -128,6 +163,16 @@ const QwenCodeSettings: Component = () => {
{formatRemainingRequests(user()!)} {formatRemainingRequests(user()!)}
</span> </span>
</Show> </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> </div>
</div> </div>
@@ -188,4 +233,4 @@ const QwenCodeSettings: Component = () => {
) )
} }
export default QwenCodeSettings export default QwenCodeSettings

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"> <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> <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"> <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 <a
href="https://z.ai/manage-apikey/apikey-list" href="https://z.ai/manage-apikey/apikey-list"
target="_blank" target="_blank"
@@ -182,12 +182,11 @@ const ZAISettings: Component = () => {
</p> </p>
</div> </div>
{/* Endpoint */}
<div> <div>
<label class="block font-medium mb-2">Endpoint</label> <label class="block font-medium mb-2">Endpoint</label>
<input <input
type="text" type="text"
placeholder="https://api.z.ai/api/anthropic" placeholder="https://api.z.ai/api/paas/v4"
value={config().endpoint || ''} value={config().endpoint || ''}
onChange={(e) => handleConfigChange('endpoint', e.target.value)} 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" 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_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600 const TOOL_SCROLL_INTENT_WINDOW_MS = 600
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
function makeRenderCacheKey( function makeRenderCacheKey(
toolCallId?: string | null, toolCallId?: string | null,
@@ -304,6 +305,7 @@ export default function ToolCall(props: ToolCallProps) {
let toolCallRootRef: HTMLDivElement | undefined let toolCallRootRef: HTMLDivElement | undefined
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined let detachScrollIntentListeners: (() => void) | undefined
let lastFileEventKey = ""
let pendingScrollFrame: number | null = null let pendingScrollFrame: number | null = null
let pendingAnchorScroll: 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(() => { createEffect(() => {
const activeKey = activePermissionKey() const activeKey = activePermissionKey()
if (!activeKey) return 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/markdown.css';
@import './styles/tabs.css'; @import './styles/tabs.css';
@import './styles/antigravity.css'; @import './styles/antigravity.css';
@import './styles/responsive.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -18,6 +19,7 @@
html,
body { body {
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); font-size: var(--font-size-base);
@@ -27,12 +29,18 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--surface-base); background-color: var(--surface-base);
color: var(--text-primary); color: var(--text-primary);
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
} }
#root { #root {
width: 100vw; width: 100%;
height: 100vh; height: 100%;
background-color: var(--surface-base); 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, WorkspaceEventPayload,
WorkspaceEventType, WorkspaceEventType,
WorkspaceGitStatus, WorkspaceGitStatus,
WorkspaceExportRequest,
WorkspaceExportResponse,
WorkspaceImportRequest,
WorkspaceImportResponse,
WorkspaceMcpConfigRequest,
WorkspaceMcpConfigResponse,
PortAvailabilityResponse,
} from "../../../server/src/api-types" } from "../../../server/src/api-types"
import { getLogger } from "./logger" import { getLogger } from "./logger"
@@ -158,6 +165,27 @@ export const serverApi = {
fetchWorkspaceGitStatus(id: string): Promise<WorkspaceGitStatus> { fetchWorkspaceGitStatus(id: string): Promise<WorkspaceGitStatus> {
return request<WorkspaceGitStatus>(`/api/workspaces/${encodeURIComponent(id)}/git/status`) 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> { fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app") return request<AppConfig>("/api/config/app")
@@ -241,6 +269,9 @@ export const serverApi = {
const params = new URLSearchParams({ id }) const params = new URLSearchParams({ id })
return request<SkillDetail>(`/api/skills/detail?${params.toString()}`) return request<SkillDetail>(`/api/skills/detail?${params.toString()}`)
}, },
fetchAvailablePort(): Promise<PortAvailabilityResponse> {
return request<PortAvailabilityResponse>("/api/ports/available")
},
} }
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType } 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, getSessions,
setActiveSession, setActiveSession,
} from "../../stores/sessions" } from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction" import { compactSession } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance" import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types" import type { MessageRecord } from "../../stores/message-v2/types"
@@ -235,21 +235,9 @@ export function useCommands(options: UseCommandsOptions) {
const sessionId = activeSessionIdForInstance() const sessionId = activeSessionIdForInstance()
if (!instance || !instance.client || !sessionId || sessionId === "info") return 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 { try {
setSessionCompactionState(instance.id, sessionId, true) await compactSession(instance.id, sessionId)
await instance.client.session.summarize({
path: { id: sessionId },
body: {
providerID: session.model.providerId,
modelID: session.model.modelId,
},
})
} catch (error) { } catch (error) {
setSessionCompactionState(instance.id, sessionId, false)
log.error("Failed to compact session", error) log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session" const message = error instanceof Error ? error.message : "Failed to compact session"
showAlertDialog(`Compact failed: ${message}`, { 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 { nanoid } from 'nanoid'
import type { AxiosInstance, AxiosResponse } from 'axios'
import { createSignal, onMount } from 'solid-js' import { createSignal, onMount } from 'solid-js'
import { getUserScopedKey } from "../user-storage"
// Configuration schema // Configuration schema
export interface QwenConfig { export interface QwenConfig {
@@ -13,6 +13,7 @@ export interface QwenConfig {
redirectUri?: string redirectUri?: string
scope?: string scope?: string
baseUrl?: string baseUrl?: string
apiBaseUrl?: string
} }
export interface QwenAuthToken { export interface QwenAuthToken {
@@ -22,6 +23,7 @@ export interface QwenAuthToken {
refresh_token?: string refresh_token?: string
scope?: string scope?: string
created_at: number created_at: number
resource_url?: string
} }
export interface QwenUser { export interface QwenUser {
@@ -43,82 +45,77 @@ export interface QwenOAuthState {
redirect_uri: string 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 { export class QwenOAuthManager {
private config: Required<QwenConfig> private config: { clientId: string; redirectUri: string; scope: string; baseUrl: string }
private tokenStorageKey = 'qwen_oauth_token' private tokenStorageKey = getUserScopedKey('qwen_oauth_token')
private userStorageKey = 'qwen_user_info' private userStorageKey = getUserScopedKey('qwen_user_info')
constructor(config: QwenConfig = {}) { constructor(config: QwenConfig = {}) {
this.config = { this.config = {
clientId: config.clientId || 'qwen-code-client', clientId: config.clientId || 'qwen-code-client',
redirectUri: config.redirectUri || `${window.location.origin}/auth/qwen/callback`, redirectUri: config.redirectUri || `${window.location.origin}/auth/qwen/callback`,
scope: config.scope || 'read write', scope: config.scope || 'openid profile email model.completion',
baseUrl: config.baseUrl || 'https://qwen.ai' baseUrl: config.apiBaseUrl || config.baseUrl || ''
} }
} }
/** /**
* Generate OAuth URL for authentication * Request device authorization for Qwen OAuth
*/ */
async generateAuthUrl(): Promise<{ url: string; state: QwenOAuthState }> { async requestDeviceAuthorization(codeChallenge: string): Promise<{
const state = await this.generateOAuthState() device_code: string
const params = new URLSearchParams({ user_code: string
response_type: 'code', verification_uri: string
client_id: this.config.clientId, verification_uri_complete: string
redirect_uri: this.config.redirectUri, expires_in: number
scope: this.config.scope, }> {
state: state.state, const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/device`, {
code_challenge: state.code_challenge, method: 'POST',
code_challenge_method: 'S256' headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
}) })
const authUrl = `${this.config.baseUrl}/oauth/authorize?${params.toString()}` if (!response.ok) {
const message = await response.text()
return { throw new Error(`Device authorization failed: ${message}`)
url: authUrl,
state
} }
return await response.json()
} }
/** /**
* Exchange authorization code for access token * Poll device token endpoint
*/ */
async exchangeCodeForToken(code: string, state: string): Promise<QwenAuthToken> { async pollDeviceToken(deviceCode: string, codeVerifier: string): Promise<any> {
const storedState = this.getOAuthState(state) const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/token`, {
if (!storedState) { method: 'POST',
throw new Error('Invalid OAuth state') headers: {
} 'Content-Type': 'application/json'
},
try { body: JSON.stringify({
const response = await fetch(`${this.config.baseUrl}/oauth/token`, { device_code: deviceCode,
method: 'POST', code_verifier: codeVerifier
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.config.clientId,
code,
redirect_uri: this.config.redirectUri,
code_verifier: storedState.code_verifier
})
}) })
})
if (!response.ok) { const rawText = await response.text()
throw new Error(`Token exchange failed: ${response.statusText}`) try {
} return JSON.parse(rawText)
} catch {
const tokenData = await response.json() throw new Error(`Token poll failed: ${rawText}`)
const token = this.parseTokenResponse(tokenData)
// Store token
this.storeToken(token)
this.clearOAuthState(state)
return token
} catch (error) {
this.clearOAuthState(state)
throw error
} }
} }
@@ -132,14 +129,12 @@ export class QwenOAuthManager {
} }
try { try {
const response = await fetch(`${this.config.baseUrl}/oauth/token`, { const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/refresh`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/json',
}, },
body: new URLSearchParams({ body: JSON.stringify({
grant_type: 'refresh_token',
client_id: this.config.clientId,
refresh_token: currentToken.refresh_token refresh_token: currentToken.refresh_token
}) })
}) })
@@ -151,7 +146,7 @@ export class QwenOAuthManager {
const tokenData = await response.json() const tokenData = await response.json()
const token = this.parseTokenResponse(tokenData) const token = this.parseTokenResponse(tokenData)
this.storeToken(token) this.storeToken(token)
return token return token
} catch (error) { } catch (error) {
// If refresh fails, clear stored token // If refresh fails, clear stored token
@@ -169,17 +164,28 @@ export class QwenOAuthManager {
throw new Error('Not authenticated') throw new Error('Not authenticated')
} }
const response = await fetch(`${this.config.baseUrl}/api/user`, { try {
headers: { const response = await fetch(`/api/qwen/user`, {
'Authorization': `Bearer ${token.access_token}` headers: {
'Authorization': `Bearer ${token.access_token}`
}
})
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`)
}
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
}
} }
})
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`)
} }
return await response.json()
} }
/** /**
@@ -191,11 +197,7 @@ export class QwenOAuthManager {
return null return null
} }
// Check if token is expired (with 5-minute buffer) if (this.isTokenExpired(token)) {
const now = Date.now()
const expiresAt = (token.created_at + token.expires_in) * 1000 - 300000 // 5 min buffer
if (now >= expiresAt) {
try { try {
return await this.refreshToken() return await this.refreshToken()
} catch (error) { } catch (error) {
@@ -207,37 +209,6 @@ export class QwenOAuthManager {
return token 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 * Sign out user
*/ */
@@ -250,8 +221,9 @@ export class QwenOAuthManager {
* Check if user is authenticated * Check if user is authenticated
*/ */
isAuthenticated(): boolean { isAuthenticated(): boolean {
const token = this.getValidToken() const token = this.getStoredToken()
return token !== null if (!token) return false
return !this.isTokenExpired(token)
} }
/** /**
@@ -269,7 +241,7 @@ export class QwenOAuthManager {
/** /**
* Store user info * Store user info
*/ */
private storeUserInfo(user: QwenUser): void { storeUserInfo(user: QwenUser): void {
localStorage.setItem(this.userStorageKey, JSON.stringify(user)) localStorage.setItem(this.userStorageKey, JSON.stringify(user))
} }
@@ -297,7 +269,7 @@ export class QwenOAuthManager {
// Store state temporarily // Store state temporarily
sessionStorage.setItem(`qwen_oauth_${state}`, JSON.stringify(oauthState)) sessionStorage.setItem(`qwen_oauth_${state}`, JSON.stringify(oauthState))
return oauthState return oauthState
} }
@@ -323,38 +295,34 @@ export class QwenOAuthManager {
/** /**
* Generate code verifier for PKCE * Generate code verifier for PKCE
*/ */
private generateCodeVerifier(): string { generateCodeVerifier(): string {
const array = new Uint8Array(32) const array = new Uint8Array(32)
crypto.getRandomValues(array) crypto.getRandomValues(array)
return Array.from(array, byte => String.fromCharCode(byte)).join('') return toBase64Url(array)
} }
/** /**
* Generate code challenge for PKCE * Generate code challenge for PKCE
*/ */
private async generateCodeChallenge(verifier: string): Promise<string> { async generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const data = encoder.encode(verifier) const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data) const digest = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(digest)) return toBase64Url(new Uint8Array(digest))
.map(b => String.fromCharCode(b))
.join('')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
} }
/** /**
* Parse token response * Parse token response
*/ */
private parseTokenResponse(data: any): QwenAuthToken { parseTokenResponse(data: any): QwenAuthToken {
const token: QwenAuthToken = { const token: QwenAuthToken = {
access_token: data.access_token, access_token: data.access_token,
token_type: data.token_type, token_type: data.token_type,
expires_in: data.expires_in, expires_in: data.expires_in,
refresh_token: data.refresh_token, refresh_token: data.refresh_token,
scope: data.scope, scope: data.scope,
created_at: Date.now() resource_url: data.resource_url,
created_at: Math.floor(Date.now() / 1000)
} }
return token return token
@@ -363,7 +331,7 @@ export class QwenOAuthManager {
/** /**
* Store token * Store token
*/ */
private storeToken(token: QwenAuthToken): void { storeToken(token: QwenAuthToken): void {
localStorage.setItem(this.tokenStorageKey, JSON.stringify(token)) 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 * Clear token
*/ */
@@ -393,70 +371,82 @@ export function useQwenOAuth(config?: QwenConfig) {
const [isAuthenticated, setIsAuthenticated] = createSignal(false) const [isAuthenticated, setIsAuthenticated] = createSignal(false)
const [user, setUser] = createSignal<QwenUser | null>(null) const [user, setUser] = createSignal<QwenUser | null>(null)
const [isLoading, setIsLoading] = createSignal(false) const [isLoading, setIsLoading] = createSignal(false)
const [tokenInfo, setTokenInfo] = createSignal<QwenAuthToken | null>(null)
// Check authentication status on mount // Check authentication status on mount
onMount(() => { onMount(() => {
const manager = authManager() const manager = authManager()
if (manager.isAuthenticated()) { manager.getValidToken().then((token) => {
if (!token) return
setIsAuthenticated(true) setIsAuthenticated(true)
setTokenInfo(manager.getTokenInfo())
const userInfo = manager.getUserInfo() const userInfo = manager.getUserInfo()
if (userInfo) { if (userInfo) {
setUser(userInfo) setUser(userInfo)
} }
} }).catch(() => {
setIsAuthenticated(false)
})
}) })
const signIn = async () => { const signIn = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const manager = authManager() const manager = authManager()
const { url, state } = await manager.generateAuthUrl() const codeVerifier = manager.generateCodeVerifier()
const codeChallenge = await manager.generateCodeChallenge(codeVerifier)
// Open popup window for OAuth const deviceAuth = await manager.requestDeviceAuthorization(codeChallenge)
const popup = window.open( const popup = window.open(
url, deviceAuth.verification_uri_complete,
'qwen-oauth', 'qwen-oauth',
'width=500,height=600,scrollbars=yes,resizable=yes' 'width=500,height=600,scrollbars=yes,resizable=yes'
) )
if (!popup) { 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 expiresAt = Date.now() + deviceAuth.expires_in * 1000
const checkClosed = setInterval(() => { let pollInterval = 2000
if (popup.closed) {
clearInterval(checkClosed)
setIsLoading(false)
}
}, 1000)
// Listen for message from popup while (Date.now() < expiresAt) {
const messageListener = async (event: MessageEvent) => { const tokenData = await manager.pollDeviceToken(deviceAuth.device_code, codeVerifier)
if (event.origin !== window.location.origin) return
if (tokenData?.access_token) {
if (event.data.type === 'QWEN_OAUTH_SUCCESS') { const token = manager.parseTokenResponse(tokenData)
const { code, state } = event.data manager.storeToken(token)
await manager.exchangeCodeForToken(code, state) setTokenInfo(manager.getTokenInfo())
const userInfo = await manager.fetchUserInfo() const userInfo = await manager.fetchUserInfo()
setUser(userInfo) if (userInfo) {
manager.storeUserInfo(userInfo)
setUser(userInfo)
} else {
setUser(null)
}
setIsAuthenticated(true) setIsAuthenticated(true)
setIsLoading(false) setIsLoading(false)
popup.close() popup?.close()
} else if (event.data.type === 'QWEN_OAUTH_ERROR') { return
setIsLoading(false)
popup.close()
} }
if (tokenData?.error === 'authorization_pending') {
await new Promise((resolve) => setTimeout(resolve, pollInterval))
continue
}
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')
} }
window.addEventListener('message', messageListener) throw new Error('OAuth timed out')
// Cleanup
setTimeout(() => {
clearInterval(checkClosed)
window.removeEventListener('message', messageListener)
setIsLoading(false)
}, 300000) // 5 minute timeout
} catch (error) { } catch (error) {
setIsLoading(false) setIsLoading(false)
@@ -469,18 +459,15 @@ export function useQwenOAuth(config?: QwenConfig) {
manager.signOut() manager.signOut()
setIsAuthenticated(false) setIsAuthenticated(false)
setUser(null) setUser(null)
} setTokenInfo(null)
const createApiClient = () => {
return authManager().createApiClient()
} }
return { return {
isAuthenticated: () => isAuthenticated(), isAuthenticated: () => isAuthenticated(),
user: () => user(), user: () => user(),
isLoading: () => isLoading(), isLoading: () => isLoading(),
tokenInfo: () => tokenInfo(),
signIn, signIn,
signOut, signOut
createApiClient
} }
} }

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 { export interface SessionSidebarRequestDetail {
instanceId: string 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,32 +1,36 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" /> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta charset="UTF-8" />
<title>CodeNomad</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style> <title>NomadArch</title>
:root { <style>
color-scheme: dark; :root {
color-scheme: dark;
}
html,
body {
background-color: #1a1a1a;
color: #e0e0e0;
}
</style>
<script>
; (function () {
try {
document.documentElement.setAttribute('data-theme', 'dark')
} catch (error) {
const rawConsole = globalThis?.["console"]
rawConsole?.warn?.('Failed to apply initial theme', error)
} }
html, })()
body { </script>
background-color: #1a1a1a; </head>
color: #e0e0e0;
} <body>
</style> <div id="root"></div>
<script> <script type="module" src="./main.tsx"></script>
;(function () { </body>
try {
document.documentElement.setAttribute('data-theme', 'dark') </html>
} catch (error) {
const rawConsole = globalThis?.["console"]
rawConsole?.warn?.('Failed to apply initial theme', error)
}
})()
</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; max-width: 520px;
width: 100%; width: 100%;
text-align: center; text-align: center;
animation: fadeIn 0.4s ease-out;
} }
.loading-logo { .loading-logo {
width: 180px; width: 180px;
height: auto; height: auto;
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45)); filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
animation: logoPulse 3s ease-in-out infinite;
} }
.loading-heading { .loading-heading {
@@ -54,6 +56,7 @@ button {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
color: var(--text-muted, #aeb3c4); color: var(--text-muted, #aeb3c4);
animation: fadeIn 0.3s ease-out;
} }
.loading-card { .loading-card {
@@ -64,7 +67,13 @@ button {
border-radius: 18px; border-radius: 18px;
background: rgba(13, 16, 24, 0.85); background: rgba(13, 16, 24, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08); 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 { .loading-row {
@@ -81,7 +90,8 @@ button {
border-radius: 50%; border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18); border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff; 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 { .phrase-controls {
@@ -93,12 +103,29 @@ button {
.phrase-controls button { .phrase-controls button {
color: #8fb5ff; color: #8fb5ff;
cursor: pointer; 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 { .loading-error {
margin-top: 12px; 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; color: #ff9ea9;
font-size: 0.95rem; font-size: 0.9rem;
animation: fadeIn 0.3s ease-out;
} }
@keyframes spin { @keyframes spin {
@@ -109,3 +136,23 @@ button {
transform: rotate(360deg); 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 { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web" 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 { runtimeEnv, isTauriHost } from "../../lib/runtime-env"
import "../../index.css" import "../../index.css"
import "./loading.css" import "./loading.css"
@@ -202,7 +202,7 @@ function LoadingApp() {
<img src={iconUrl} alt="NomadArch" class="loading-logo" width="180" height="180" /> <img src={iconUrl} alt="NomadArch" class="loading-logo" width="180" height="180" />
<div class="loading-heading"> <div class="loading-heading">
<h1 class="loading-title">NomadArch 1.0</h1> <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> <Show when={status()}>{(statusText) => <p class="loading-status">{statusText()}</p>}</Show>
</div> </div>
<div class="loading-card"> <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: {}, agentModelSelections: {},
sessionTasks: {}, sessionTasks: {},
sessionSkills: {}, sessionSkills: {},
customAgents: [],
} }
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map()) const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
@@ -24,6 +25,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
agentModelSelections: { ...(source.agentModelSelections ?? {}) }, agentModelSelections: { ...(source.agentModelSelections ?? {}) },
sessionTasks: { ...(source.sessionTasks ?? {}) }, sessionTasks: { ...(source.sessionTasks ?? {}) },
sessionSkills: { ...(source.sessionSkills ?? {}) }, sessionSkills: { ...(source.sessionSkills ?? {}) },
customAgents: Array.isArray(source.customAgents) ? [...source.customAgents] : [],
} }
} }

View File

@@ -87,3 +87,20 @@ class MessageStoreBus {
} }
export const messageStoreBus = new 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) { if (!activeToast || activeToastVersion !== release.version) {
dismissActiveToast() dismissActiveToast()
activeToast = showToastNotification({ activeToast = showToastNotification({
title: `CodeNomad ${release.version}`, title: `NomadArch ${release.version}`,
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.", message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
variant: "info", variant: "info",
duration: Number.POSITIVE_INFINITY, duration: Number.POSITIVE_INFINITY,

View File

@@ -9,12 +9,20 @@ import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import { buildRecordDisplayData } from "./message-v2/record-display-cache" import { buildRecordDisplayData } from "./message-v2/record-display-cache"
import { getLogger } from "../lib/logger" 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 { createSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { showConfirmDialog } from "./alerts"
import { QwenOAuthManager } from "../lib/integrations/qwen-oauth" import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
import { getUserScopedKey } from "../lib/user-storage"
import { loadSkillDetails } from "./skills" import { loadSkillDetails } from "./skills"
import { serverApi } from "../lib/api-client"
const log = getLogger("actions") const log = getLogger("actions")
@@ -28,16 +36,18 @@ const COMPACTION_ATTEMPT_TTL_MS = 60_000
const COMPACTION_SUMMARY_MAX_CHARS = 4000 const COMPACTION_SUMMARY_MAX_CHARS = 4000
const STREAM_TIMEOUT_MS = 120_000 const STREAM_TIMEOUT_MS = 120_000
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models" const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
function markOpencodeZenModelOffline(modelId: string): void { function markOpencodeZenModelOffline(modelId: string): void {
if (typeof window === "undefined" || !modelId) return if (typeof window === "undefined" || !modelId) return
try { 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 parsed = raw ? JSON.parse(raw) : []
const list = Array.isArray(parsed) ? parsed : [] const list = Array.isArray(parsed) ? parsed : []
if (!list.includes(modelId)) { if (!list.includes(modelId)) {
list.push(modelId) list.push(modelId)
window.localStorage.setItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY, JSON.stringify(list)) window.localStorage.setItem(key, JSON.stringify(list))
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("opencode-zen-offline-models", { detail: { modelId } }), new CustomEvent("opencode-zen-offline-models", { detail: { modelId } }),
) )
@@ -209,21 +219,11 @@ async function checkTokenBudgetBeforeSend(
warningThreshold, warningThreshold,
}) })
const confirmed = await showConfirmDialog( setCompactionSuggestion(
`Context limit approaching (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens). Compact now to continue?`, instanceId,
{ sessionId,
title: "Token Budget Warning", `Context usage is high (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens).`,
confirmLabel: "Compact",
cancelLabel: "Continue Anyway",
},
) )
if (confirmed) {
setTokenWarningState(instanceId, sessionId, true)
await compactSession(instanceId, sessionId)
return false
}
setTokenWarningState(instanceId, sessionId, true) setTokenWarningState(instanceId, sessionId, true)
return true return true
} }
@@ -233,6 +233,8 @@ async function checkTokenBudgetBeforeSend(
type ExternalChatMessage = { role: "user" | "assistant" | "system"; content: string } type ExternalChatMessage = { role: "user" | "assistant" | "system"; content: string }
const MAX_ATTACHMENT_CHARS = 8000
function shouldForceEnglish(prompt: string): boolean { function shouldForceEnglish(prompt: string): boolean {
const text = prompt.trim() const text = prompt.trim()
if (!text) return false 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." 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 { function clampText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value if (value.length <= maxChars) return value
return `${value.slice(0, Math.max(0, maxChars - 3))}...` return `${value.slice(0, Math.max(0, maxChars - 3))}...`
@@ -290,18 +299,46 @@ async function mergeSystemInstructions(
Promise.resolve(buildLanguageSystemInstruction(prompt)), Promise.resolve(buildLanguageSystemInstruction(prompt)),
buildSkillsSystemInstruction(instanceId, sessionId), buildSkillsSystemInstruction(instanceId, sessionId),
]) ])
if (languageSystem && skillsSystem) { const sshInstruction = buildSshPasswordInstruction(prompt)
return `${languageSystem}\n\n${skillsSystem}` const sections = [languageSystem, skillsSystem, sshInstruction].filter(Boolean) as string[]
} if (sections.length === 0) return undefined
return languageSystem || skillsSystem 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[] = [] const segments: string[] = []
for (const part of parts) { for (const part of parts) {
if (!part || typeof part !== "object") continue if (!part || typeof part !== "object") continue
if (part.type === "text" && typeof part.text === "string") { if (part.type === "text" || part.type === "reasoning") {
segments.push(part.text) collectTextSegments(part.text, segments)
} else if (part.type === "file" && typeof part.filename === "string") { } else if (part.type === "file" && typeof part.filename === "string") {
segments.push(`[file: ${part.filename}]`) segments.push(`[file: ${part.filename}]`)
} }
@@ -337,6 +374,62 @@ function buildExternalChatMessages(
return messages 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( async function readSseStream(
response: Response, response: Response,
onData: (data: string) => void, onData: (data: string) => void,
@@ -396,7 +489,7 @@ async function streamOllamaChat(
sessionId: string, sessionId: string,
providerId: string, providerId: string,
modelId: string, modelId: string,
systemMessage: string | undefined, messages: ExternalChatMessage[],
messageId: string, messageId: string,
assistantMessageId: string, assistantMessageId: string,
assistantPartId: string, assistantPartId: string,
@@ -410,7 +503,7 @@ async function streamOllamaChat(
signal: controller.signal, signal: controller.signal,
body: JSON.stringify({ body: JSON.stringify({
model: modelId, model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage), messages,
stream: true, stream: true,
}), }),
}) })
@@ -477,7 +570,7 @@ async function streamQwenChat(
sessionId: string, sessionId: string,
providerId: string, providerId: string,
modelId: string, modelId: string,
systemMessage: string | undefined, messages: ExternalChatMessage[],
accessToken: string, accessToken: string,
resourceUrl: string | undefined, resourceUrl: string | undefined,
messageId: string, messageId: string,
@@ -496,7 +589,7 @@ async function streamQwenChat(
signal: controller.signal, signal: controller.signal,
body: JSON.stringify({ body: JSON.stringify({
model: modelId, model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage), messages,
stream: true, stream: true,
resource_url: resourceUrl, resource_url: resourceUrl,
}), }),
@@ -561,7 +654,7 @@ async function streamOpenCodeZenChat(
sessionId: string, sessionId: string,
providerId: string, providerId: string,
modelId: string, modelId: string,
systemMessage: string | undefined, messages: ExternalChatMessage[],
messageId: string, messageId: string,
assistantMessageId: string, assistantMessageId: string,
assistantPartId: string, assistantPartId: string,
@@ -575,7 +668,7 @@ async function streamOpenCodeZenChat(
signal: controller.signal, signal: controller.signal,
body: JSON.stringify({ body: JSON.stringify({
model: modelId, model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage), messages,
stream: true, stream: true,
}), }),
}) })
@@ -645,7 +738,7 @@ async function streamZAIChat(
sessionId: string, sessionId: string,
providerId: string, providerId: string,
modelId: string, modelId: string,
systemMessage: string | undefined, messages: ExternalChatMessage[],
messageId: string, messageId: string,
assistantMessageId: string, assistantMessageId: string,
assistantPartId: string, assistantPartId: string,
@@ -659,7 +752,7 @@ async function streamZAIChat(
signal: controller.signal, signal: controller.signal,
body: JSON.stringify({ body: JSON.stringify({
model: modelId, model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage), messages,
stream: true, stream: true,
}), }),
}) })
@@ -868,6 +961,12 @@ async function sendMessage(
const now = Date.now() const now = Date.now()
const assistantMessageId = createId("msg") const assistantMessageId = createId("msg")
const assistantPartId = createId("part") const assistantPartId = createId("part")
const externalMessages = await buildExternalChatMessagesWithAttachments(
instanceId,
sessionId,
systemMessage,
attachments,
)
store.upsertMessage({ store.upsertMessage({
id: assistantMessageId, id: assistantMessageId,
@@ -902,7 +1001,7 @@ async function sendMessage(
sessionId, sessionId,
providerId, providerId,
effectiveModel.modelId, effectiveModel.modelId,
systemMessage, externalMessages,
messageId, messageId,
assistantMessageId, assistantMessageId,
assistantPartId, assistantPartId,
@@ -913,7 +1012,7 @@ async function sendMessage(
sessionId, sessionId,
providerId, providerId,
effectiveModel.modelId, effectiveModel.modelId,
systemMessage, externalMessages,
messageId, messageId,
assistantMessageId, assistantMessageId,
assistantPartId, assistantPartId,
@@ -924,7 +1023,7 @@ async function sendMessage(
sessionId, sessionId,
providerId, providerId,
effectiveModel.modelId, effectiveModel.modelId,
systemMessage, externalMessages,
messageId, messageId,
assistantMessageId, assistantMessageId,
assistantPartId, assistantPartId,
@@ -962,7 +1061,7 @@ async function sendMessage(
sessionId, sessionId,
providerId, providerId,
effectiveModel.modelId, effectiveModel.modelId,
systemMessage, externalMessages,
token.access_token, token.access_token,
token.resource_url, token.resource_url,
messageId, messageId,
@@ -1151,12 +1250,29 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
} }
const agent = session.agent || "build" 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({ await instance.client.session.shell({
path: { id: sessionId }, path: { id: sessionId },
body: { body: {
agent, 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 }) log.info("compactSession: Complete", { instanceId, sessionId, compactedSessionId: compactedSession.id })
clearCompactionSuggestion(instanceId, sessionId)
return { return {
...result, ...result,
token_before: tokenBefore, token_before: tokenBefore,
@@ -1407,6 +1524,30 @@ async function updateSessionModel(
updateSessionInfo(instanceId, sessionId) 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> { async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
const instance = instances().get(instanceId) const instance = instances().get(instanceId)
if (!instance || !instance.client) { if (!instance || !instance.client) {
@@ -1500,4 +1641,5 @@ export {
sendMessage, sendMessage,
updateSessionAgent, updateSessionAgent,
updateSessionModel, updateSessionModel,
updateSessionModelForSession,
} }

View File

@@ -33,6 +33,7 @@ import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache" import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("api") const log = getLogger("api")
@@ -147,7 +148,7 @@ function getStoredQwenToken():
| null { | null {
if (typeof window === "undefined") return null if (typeof window === "undefined") return null
try { try {
const raw = window.localStorage.getItem("qwen_oauth_token") const raw = window.localStorage.getItem(getUserScopedKey("qwen_oauth_token"))
if (!raw) return null if (!raw) return null
return JSON.parse(raw) return JSON.parse(raw)
} catch { } catch {
@@ -689,6 +690,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
} }
try { try {
await ensureInstanceConfigLoaded(instanceId)
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`) log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
const response = await instance.client.app.agents() const response = await instance.client.app.agents()
const agentList = (response.data ?? []).map((agent) => ({ const agentList = (response.data ?? []).map((agent) => ({
@@ -703,9 +705,16 @@ async function fetchAgents(instanceId: string): Promise<void> {
: undefined, : undefined,
})) }))
const customAgents = getInstanceConfig(instanceId)?.customAgents ?? []
const customList = customAgents.map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: "custom",
}))
setAgents((prev) => { setAgents((prev) => {
const next = new Map(prev) const next = new Map(prev)
next.set(instanceId, agentList) next.set(instanceId, [...agentList, ...customList])
return next return next
}) })
} catch (error) { } 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 { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances" import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store" 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 { showAlertDialog } from "./alerts"
import { sessions, setSessions, withSession } from "./session-state" import { sessions, setSessions, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
import { addTaskMessage, replaceTaskMessageId } from "./task-actions" import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
import { checkAndTriggerAutoCompact, getSessionCompactionState, setCompactionSuggestion } from "./session-compaction"
const log = getLogger("sse") const log = getLogger("sse")
import { loadMessages } from "./session-api" import { loadMessages } from "./session-api"
@@ -39,6 +40,7 @@ import {
} from "./message-v2/bridge" } from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import type { InstanceMessageStore } from "./message-v2/instance-store" import type { InstanceMessageStore } from "./message-v2/instance-store"
import { getDefaultModel } from "./session-models"
interface TuiToastEvent { interface TuiToastEvent {
type: "tui.toast.show" type: "tui.toast.show"
@@ -232,6 +234,16 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true }) upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
updateSessionInfo(instanceId, sessionId) 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 { function handleSessionError(instanceId: string, event: EventSessionError): void {
const error = event.properties?.error const error = event.properties?.error
log.error(`[SSE] Session error:`, error) log.error(`[SSE] Session error:`, error)
@@ -406,18 +433,73 @@ function handleSessionError(instanceId: string, event: EventSessionError): void
// Autonomous error recovery for SOLO // Autonomous error recovery for SOLO
const solo = getSoloState(instanceId) const solo = getSoloState(instanceId)
const sessionId = (event.properties as any)?.sessionID const sessionId = (event.properties as any)?.sessionID
if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) { if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) {
log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`) log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`)
incrementStep(instanceId) incrementStep(instanceId)
sendMessage(instanceId, sessionId, `I encountered an error: "${message}". Please analyze the cause and provide a fix.`, [], solo.activeTaskId || undefined).catch((err) => { 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) log.error("[SOLO] Failed to send error recovery message", err)
}) })
} else { return
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
} }
// 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 { 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 { MessageInfo } from "../types/message"
import type { MessageRecord } from "./message-v2/types" import type { MessageRecord } from "./message-v2/types"
import { sessions } from "./sessions" import { sessions } from "./sessions"
import { isSessionCompactionActive } from "./session-compaction" import { getSessionCompactionState } from "./session-compaction"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
function getSession(instanceId: string, sessionId: string): Session | null { 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) const store = messageStoreBus.getOrCreate(instanceId)
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) { if (getSessionCompactionState(instanceId, sessionId) || isSessionCompacting(session)) {
return "compacting" 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 { Task, TaskStatus } from "../types/session"
import { nanoid } from "nanoid" 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 { export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
withSession(instanceId, sessionId, (session) => { withSession(instanceId, sessionId, (session) => {
@@ -18,13 +19,32 @@ export async function addTask(
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id }); console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
let taskSessionId: string | undefined let taskSessionId: string | undefined
const parentSession = sessions().get(instanceId)?.get(sessionId)
const parentAgent = parentSession?.agent || ""
const parentModel = parentSession?.model
try { try {
console.log("[task-actions] forking session..."); console.log("[task-actions] creating new task session...");
const forked = await forkSession(instanceId, sessionId) const created = await createSession(instanceId, parentAgent || undefined, { skipAutoCleanup: true })
taskSessionId = forked.id taskSessionId = created.id
console.log("[task-actions] fork successful", { taskSessionId }); 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) { } 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 = { const newTask: Task = {
@@ -34,6 +54,7 @@ export async function addTask(
timestamp: Date.now(), timestamp: Date.now(),
messageIds: [], messageIds: [],
taskSessionId, taskSessionId,
archived: false,
} }
withSession(instanceId, sessionId, (session) => { 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 [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false) const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
const [showFolderSelection, setShowFolderSelection] = createSignal(false) const [showFolderSelection, setShowFolderSelection] = createSignal(false)
const [showFolderSelectionOnStart, setShowFolderSelectionOnStart] = createSignal(true)
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([]) const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map()) const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
@@ -29,6 +30,8 @@ export {
setIsSelectingFolder, setIsSelectingFolder,
showFolderSelection, showFolderSelection,
setShowFolderSelection, setShowFolderSelection,
showFolderSelectionOnStart,
setShowFolderSelectionOnStart,
instanceTabOrder, instanceTabOrder,
setInstanceTabOrder, setInstanceTabOrder,
sessionTabOrder, 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/env-vars.css";
@import "./components/directory-browser.css"; @import "./components/directory-browser.css";
@import "./components/remote-access.css"; @import "./components/remote-access.css";
@import "./components/mcp-manager.css";

View File

@@ -53,6 +53,20 @@
@apply text-[11px] text-[var(--text-muted)]; @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 { .assistant-message {
/* gap: 0.25rem; */ /* gap: 0.25rem; */
padding: 0.6rem 0.65rem; padding: 0.6rem 0.65rem;
@@ -121,6 +135,13 @@
border-color: var(--status-error); 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 { .message-generating {
@apply text-sm italic py-2; @apply text-sm italic py-2;
color: var(--text-muted); color: var(--text-muted);
@@ -146,6 +167,58 @@
animation: pulse 1.5s ease-in-out infinite; 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 { .message-text {
font-size: var(--font-size-base); font-size: var(--font-size-base);
line-height: var(--line-height-normal); line-height: var(--line-height-normal);

View File

@@ -1,7 +1,27 @@
.message-stream { .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); background-color: var(--surface-base);
color: inherit; 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 { .message-stream-block {

View File

@@ -1,5 +1,54 @@
.message-stream-container { .message-stream-container {
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden; @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 { .connection-status {

View File

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

View File

@@ -1,6 +1,6 @@
/* Prompt input & attachment styles */ /* Prompt input & attachment styles */
.prompt-input-container { .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; padding: 1rem 1.5rem 1.5rem;
background-color: transparent; background-color: transparent;
} }
@@ -32,6 +32,20 @@
@apply absolute right-2 bottom-2 flex items-center space-x-1.5 z-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 { .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; @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 { .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); background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base); border-bottom: 1px solid var(--border-base);
} }
@@ -227,7 +227,7 @@
} }
.tool-call-diff-toggle { .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 { .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

@@ -233,23 +233,6 @@
--new-tab-bg: #3f3f46; --new-tab-bg: #3f3f46;
--new-tab-hover-bg: #52525b; --new-tab-hover-bg: #52525b;
--new-tab-text: #f5f6f8; --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;
--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-bg: var(--surface-muted);
--session-tab-active-text: var(--text-primary); --session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted); --session-tab-inactive-text: var(--text-muted);

View File

@@ -48,7 +48,22 @@
} }
.icon-danger-hover:hover { .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 { .icon-accent-hover:hover {

View File

@@ -26,6 +26,13 @@ declare global {
onCliError?: (callback: (data: unknown) => void) => () => void onCliError?: (callback: (data: unknown) => void) => () => void
getCliStatus?: () => Promise<unknown> getCliStatus?: () => Promise<unknown>
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult> 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 { interface TauriDialogModule {

View File

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