Backup before continuing from Codex 5.2 session - User storage, compaction suggestions, streaming improvements
This commit is contained in:
@@ -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
|
||||||
@@ -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) |
|
||||||
517
Install-Linux.sh
517
Install-Linux.sh
@@ -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
|
||||||
|
rm -f "$TARGET_DIR/.install-write-test"
|
||||||
|
NEEDS_FALLBACK=1
|
||||||
|
echo -e "${GREEN}[OK]${NC} Using fallback: $TARGET_DIR"
|
||||||
|
else
|
||||||
|
rm -f "$SCRIPT_DIR/.install-write-test"
|
||||||
|
echo -e "${GREEN}[OK]${NC} Write access OK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Install target: $TARGET_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Or install Node.js using NVM (Node Version Manager):"
|
echo "[STEP 3/9] Ensuring system dependencies"
|
||||||
echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash"
|
|
||||||
echo " source ~/.bashrc"
|
SUDO=""
|
||||||
echo " nvm install 20"
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo ""
|
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 "[WARN] There were $WARNINGS warning(s) during installation."
|
|
||||||
echo "Review warnings above. Most warnings are non-critical."
|
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "[STEP 5/9] Fetching OpenCode binary"
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
OPENCODE_VERSION=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
|
||||||
|
OPENCODE_BASE="https://github.com/sst/opencode/releases/download/v${OPENCODE_VERSION}"
|
||||||
|
OPENCODE_URL="${OPENCODE_BASE}/opencode-linux-${ARCH}"
|
||||||
|
CHECKSUM_URL="${OPENCODE_BASE}/checksums.txt"
|
||||||
|
|
||||||
|
if [[ -f "$BIN_DIR/opencode" ]]; then
|
||||||
|
echo -e "${GREEN}[OK]${NC} OpenCode binary already exists"
|
||||||
|
else
|
||||||
|
echo -e "${BLUE}[INFO]${NC} Downloading OpenCode v${OPENCODE_VERSION}"
|
||||||
|
curl -L -o "$BIN_DIR/opencode.tmp" "$OPENCODE_URL"
|
||||||
|
curl -L -o "$BIN_DIR/checksums.txt" "$CHECKSUM_URL"
|
||||||
|
|
||||||
|
EXPECTED_HASH=$(grep "opencode-linux-${ARCH}" "$BIN_DIR/checksums.txt" | awk '{print $1}')
|
||||||
|
ACTUAL_HASH=$(sha256sum "$BIN_DIR/opencode.tmp" | awk '{print $1}')
|
||||||
|
|
||||||
|
if [[ "$ACTUAL_HASH" == "$EXPECTED_HASH" ]]; then
|
||||||
|
mv "$BIN_DIR/opencode.tmp" "$BIN_DIR/opencode"
|
||||||
|
chmod +x "$BIN_DIR/opencode"
|
||||||
|
echo -e "${GREEN}[OK]${NC} OpenCode downloaded and verified"
|
||||||
|
else
|
||||||
|
echo -e "${RED}[ERROR]${NC} OpenCode checksum mismatch"
|
||||||
|
rm -f "$BIN_DIR/opencode.tmp"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
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
|
||||||
|
|||||||
472
Install-Mac.sh
472
Install-Mac.sh
@@ -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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "$ARCH_TYPE" in
|
||||||
|
arm64) ARCH="arm64" ;;
|
||||||
|
x86_64) ARCH="x64" ;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}[ERROR]${NC} Unsupported architecture: $ARCH_TYPE"
|
||||||
|
log "ERROR: Unsupported arch $ARCH_TYPE"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo -e "${GREEN}[OK]${NC} OS: macOS"
|
||||||
|
echo -e "${GREEN}[OK]${NC} Architecture: $ARCH_TYPE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[STEP 2/9] Checking write permissions"
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
if ! touch "$SCRIPT_DIR/.install-write-test" 2>/dev/null; then
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} No write access to $SCRIPT_DIR"
|
||||||
|
TARGET_DIR="$HOME/.nomadarch-install"
|
||||||
|
BIN_DIR="$TARGET_DIR/bin"
|
||||||
|
LOG_FILE="$TARGET_DIR/install.log"
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
if ! touch "$TARGET_DIR/.install-write-test" 2>/dev/null; then
|
||||||
|
echo -e "${RED}[ERROR]${NC} Cannot write to $TARGET_DIR"
|
||||||
|
log "ERROR: Write permission denied to fallback"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$TARGET_DIR/.install-write-test"
|
||||||
|
NEEDS_FALLBACK=1
|
||||||
|
echo -e "${GREEN}[OK]${NC} Using fallback: $TARGET_DIR"
|
||||||
else
|
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 "[WARN] There were $WARNINGS warning(s) during installation."
|
|
||||||
echo "Review warnings above. Most warnings are non-critical."
|
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "[STEP 5/9] Fetching OpenCode binary"
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
OPENCODE_VERSION=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
|
||||||
|
OPENCODE_BASE="https://github.com/sst/opencode/releases/download/v${OPENCODE_VERSION}"
|
||||||
|
OPENCODE_URL="${OPENCODE_BASE}/opencode-darwin-${ARCH}"
|
||||||
|
CHECKSUM_URL="${OPENCODE_BASE}/checksums.txt"
|
||||||
|
|
||||||
|
if [[ -f "$BIN_DIR/opencode" ]]; then
|
||||||
|
echo -e "${GREEN}[OK]${NC} OpenCode binary already exists"
|
||||||
|
else
|
||||||
|
echo -e "${BLUE}[INFO]${NC} Downloading OpenCode v${OPENCODE_VERSION}"
|
||||||
|
curl -L -o "$BIN_DIR/opencode.tmp" "$OPENCODE_URL"
|
||||||
|
curl -L -o "$BIN_DIR/checksums.txt" "$CHECKSUM_URL"
|
||||||
|
|
||||||
|
EXPECTED_HASH=$(grep "opencode-darwin-${ARCH}" "$BIN_DIR/checksums.txt" | awk '{print $1}')
|
||||||
|
ACTUAL_HASH=$(shasum -a 256 "$BIN_DIR/opencode.tmp" | awk '{print $1}')
|
||||||
|
|
||||||
|
if [[ "$ACTUAL_HASH" == "$EXPECTED_HASH" ]]; then
|
||||||
|
mv "$BIN_DIR/opencode.tmp" "$BIN_DIR/opencode"
|
||||||
|
chmod +x "$BIN_DIR/opencode"
|
||||||
|
echo -e "${GREEN}[OK]${NC} OpenCode downloaded and verified"
|
||||||
|
else
|
||||||
|
echo -e "${RED}[ERROR]${NC} OpenCode checksum mismatch"
|
||||||
|
rm -f "$BIN_DIR/opencode.tmp"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
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
|
||||||
|
|||||||
@@ -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...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Check if opencode is already installed globally
|
|
||||||
where opencode >nul 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 (
|
|
||||||
echo [OK] OpenCode is already installed globally
|
|
||||||
goto :OPENCODE_DONE
|
|
||||||
)
|
|
||||||
|
|
||||||
:: Check if opencode exists in bin/ folder
|
|
||||||
if exist "bin\opencode.exe" (
|
|
||||||
echo [OK] OpenCode binary found in bin/ folder
|
|
||||||
goto :OPENCODE_DONE
|
|
||||||
)
|
|
||||||
|
|
||||||
:: Install OpenCode CLI
|
|
||||||
echo [SETUP] OpenCode CLI not found. Installing...
|
|
||||||
echo.
|
|
||||||
echo [INFO] Attempting to install OpenCode via npm...
|
|
||||||
call npm install -g opencode-ai@latest
|
|
||||||
if %ERRORLEVEL% equ 0 (
|
|
||||||
echo [SUCCESS] OpenCode installed successfully via npm
|
|
||||||
where opencode >nul 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 (
|
|
||||||
echo [OK] OpenCode is now available in system PATH
|
|
||||||
goto :OPENCODE_DONE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [WARN] npm install failed or not in PATH, trying fallback method...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Fallback: Download from GitHub releases
|
|
||||||
echo [SETUP] Downloading OpenCode from GitHub releases...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Download Windows x64 ZIP
|
|
||||||
curl -L -o "opencode-windows-x64.zip" "https://github.com/sst/opencode/releases/latest/download/opencode-windows-x64.zip"
|
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo [ERROR] Failed to download OpenCode from GitHub!
|
echo [INFO] Git not found. Attempting to install...
|
||||||
set /a ERRORS+=1
|
if %WINGET_AVAILABLE% equ 1 (
|
||||||
goto :INSTALL_DEPS
|
winget install -e --id Git.Git --accept-source-agreements --accept-package-agreements
|
||||||
)
|
) else if %CHOCO_AVAILABLE% equ 1 (
|
||||||
|
choco install git -y
|
||||||
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!
|
echo [WARN] Git not installed (optional). Continue.
|
||||||
set /a ERRORS+=1
|
|
||||||
)
|
|
||||||
|
|
||||||
:CLEANUP
|
|
||||||
if exist "opencode-windows-x64.zip" del "opencode-windows-x64.zip"
|
|
||||||
if exist "opencode-temp" rmdir /s /q "opencode-temp"
|
|
||||||
|
|
||||||
:OPENCODE_DONE
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo [STEP 3/6] Installing NomadArch Dependencies...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Check if node_modules exists
|
|
||||||
if exist "node_modules" (
|
|
||||||
echo [INFO] node_modules found. Skipping dependency installation.
|
|
||||||
echo [INFO] To force reinstall, delete node_modules and run again.
|
|
||||||
goto :BUILD_CHECK
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [INFO] Installing root dependencies...
|
|
||||||
call npm install
|
|
||||||
if %ERRORLEVEL% neq 0 (
|
|
||||||
echo [ERROR] Failed to install root dependencies!
|
|
||||||
set /a ERRORS+=1
|
|
||||||
goto :INSTALL_REPORT
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [OK] Root dependencies installed
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo [INFO] Installing package dependencies...
|
|
||||||
|
|
||||||
:: Install server dependencies
|
|
||||||
if exist "packages\server" (
|
|
||||||
echo [INFO] Installing server dependencies...
|
|
||||||
cd packages\server
|
|
||||||
call npm install
|
|
||||||
if %ERRORLEVEL% neq 0 (
|
|
||||||
echo [WARN] Failed to install server dependencies!
|
|
||||||
set /a WARNINGS+=1
|
set /a WARNINGS+=1
|
||||||
) else (
|
|
||||||
echo [OK] Server dependencies installed
|
|
||||||
)
|
)
|
||||||
cd ..\..
|
) else (
|
||||||
|
for /f "tokens=*" %%i in ('git --version') do set GIT_VERSION=%%i
|
||||||
|
echo [OK] Git: %GIT_VERSION%
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Install UI dependencies
|
echo.
|
||||||
if exist "packages\ui" (
|
echo [STEP 4/9] Installing npm dependencies
|
||||||
echo [INFO] Installing UI dependencies...
|
cd /d "%SCRIPT_DIR%"
|
||||||
cd packages\ui
|
echo [%date% %time%] Running npm install >> "%LOG_FILE%"
|
||||||
call npm install
|
call npm install
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo [WARN] Failed to install UI dependencies!
|
echo [ERROR] npm install failed!
|
||||||
set /a WARNINGS+=1
|
echo [%date% %time%] ERROR: npm install failed >> "%LOG_FILE%"
|
||||||
|
set /a ERRORS+=1
|
||||||
|
goto :SUMMARY
|
||||||
|
)
|
||||||
|
echo [OK] Dependencies installed
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [STEP 5/9] Fetching OpenCode binary
|
||||||
|
if not exist "%BIN_DIR%" mkdir "%BIN_DIR%"
|
||||||
|
|
||||||
|
for /f "delims=" %%v in ('powershell -NoProfile -Command "(Invoke-WebRequest -UseBasicParsing https://api.github.com/repos/sst/opencode/releases/latest).Content ^| Select-String -Pattern '""tag_name""' ^| ForEach-Object { $_.Line.Split(''\"'')[3] }"') do (
|
||||||
|
set OPENCODE_VERSION=%%v
|
||||||
|
)
|
||||||
|
|
||||||
|
set OPENCODE_BASE=https://github.com/sst/opencode/releases/download/v!OPENCODE_VERSION!
|
||||||
|
set OPENCODE_URL=!OPENCODE_BASE!/opencode-windows-%ARCH%.exe
|
||||||
|
set CHECKSUM_URL=!OPENCODE_BASE!/checksums.txt
|
||||||
|
|
||||||
|
if exist "%BIN_DIR%\opencode.exe" (
|
||||||
|
echo [OK] OpenCode binary already exists
|
||||||
|
echo [%date% %time%] OpenCode binary exists, skipping download >> "%LOG_FILE%"
|
||||||
) else (
|
) else (
|
||||||
echo [OK] UI dependencies installed
|
echo [INFO] Downloading OpenCode v!OPENCODE_VERSION!...
|
||||||
)
|
if "%DOWNLOAD_CMD%"=="curl" (
|
||||||
cd ..\..
|
curl -L -o "%BIN_DIR%\opencode.exe.tmp" "!OPENCODE_URL!"
|
||||||
)
|
curl -L -o "%BIN_DIR%\checksums.txt" "!CHECKSUM_URL!"
|
||||||
|
|
||||||
:: 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 (
|
) else (
|
||||||
echo [OK] Electron app dependencies installed
|
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'"
|
||||||
cd ..\..
|
|
||||||
)
|
)
|
||||||
|
|
||||||
:BUILD_CHECK
|
set EXPECTED_HASH=
|
||||||
echo.
|
for /f "tokens=1,2" %%h in ('type "%BIN_DIR%\checksums.txt" ^| findstr /i "opencode-windows-%ARCH%"') do (
|
||||||
|
set EXPECTED_HASH=%%h
|
||||||
echo [STEP 4/6] Checking for Existing Build...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
if exist "packages\ui\dist" (
|
|
||||||
echo [OK] UI build found. Skipping build step.
|
|
||||||
echo [INFO] To rebuild, delete packages\ui\dist and run installer again.
|
|
||||||
goto :INSTALL_REPORT
|
|
||||||
)
|
)
|
||||||
|
|
||||||
echo [INFO] No UI build found. Building UI...
|
set ACTUAL_HASH=
|
||||||
echo.
|
for /f "skip=1 tokens=*" %%h in ('certutil -hashfile "%BIN_DIR%\opencode.exe.tmp" SHA256 ^| findstr /v "CertUtil" ^| findstr /v "hash of"') do (
|
||||||
|
set ACTUAL_HASH=%%h
|
||||||
|
goto :hash_found
|
||||||
|
)
|
||||||
|
:hash_found
|
||||||
|
|
||||||
:: Build UI
|
if "!ACTUAL_HASH!"=="!EXPECTED_HASH!" (
|
||||||
cd packages\ui
|
move /Y "%BIN_DIR%\opencode.exe.tmp" "%BIN_DIR%\opencode.exe"
|
||||||
|
echo [OK] OpenCode downloaded and verified
|
||||||
|
) else (
|
||||||
|
echo [ERROR] OpenCode checksum mismatch!
|
||||||
|
del "%BIN_DIR%\opencode.exe.tmp"
|
||||||
|
set /a ERRORS+=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [STEP 6/9] Building UI assets
|
||||||
|
if exist "%SCRIPT_DIR%\packages\ui\dist\index.html" (
|
||||||
|
echo [OK] UI build already exists
|
||||||
|
) else (
|
||||||
|
echo [INFO] Building UI assets...
|
||||||
|
pushd packages\ui
|
||||||
call npm run build
|
call npm run build
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo [WARN] Failed to build UI!
|
echo [ERROR] UI build failed!
|
||||||
set /a WARNINGS+=1
|
popd
|
||||||
echo [INFO] You can build manually later by running: cd packages\ui ^&^& npm run build
|
|
||||||
)
|
|
||||||
cd ..\..
|
|
||||||
|
|
||||||
:INSTALL_REPORT
|
|
||||||
echo.
|
|
||||||
echo ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
echo INSTALLATION COMPLETE
|
|
||||||
echo ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
echo.
|
|
||||||
echo Summary:
|
|
||||||
echo.
|
|
||||||
if %ERRORS% equ 0 (
|
|
||||||
echo ✓ No errors encountered
|
|
||||||
) else (
|
|
||||||
echo ✗ %ERRORS% error(s) encountered
|
|
||||||
)
|
|
||||||
echo.
|
|
||||||
if %WARNINGS% equ 0 (
|
|
||||||
echo ✓ No warnings
|
|
||||||
) else (
|
|
||||||
echo ⚠ %WARNINGS% warning(s) encountered
|
|
||||||
)
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo [STEP 5/6] Testing Installation...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Test node command
|
|
||||||
node --version >nul 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 (
|
|
||||||
echo [OK] Node.js is working
|
|
||||||
) else (
|
|
||||||
echo [FAIL] Node.js is not working correctly
|
|
||||||
set /a ERRORS+=1
|
set /a ERRORS+=1
|
||||||
|
goto :SUMMARY
|
||||||
|
)
|
||||||
|
popd
|
||||||
|
echo [OK] UI assets built successfully
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Test npm command
|
echo.
|
||||||
npm --version >nul 2>&1
|
echo [STEP 7/9] Post-install health check
|
||||||
if %ERRORLEVEL% equ 0 (
|
set HEALTH_ERRORS=0
|
||||||
echo [OK] npm is working
|
|
||||||
|
if not exist "%SCRIPT_DIR%\package.json" set /a HEALTH_ERRORS+=1
|
||||||
|
if not exist "%SCRIPT_DIR%\packages\ui" set /a HEALTH_ERRORS+=1
|
||||||
|
if not exist "%SCRIPT_DIR%\packages\server" set /a HEALTH_ERRORS+=1
|
||||||
|
if not exist "%SCRIPT_DIR%\packages\ui\dist\index.html" set /a HEALTH_ERRORS+=1
|
||||||
|
|
||||||
|
if %HEALTH_ERRORS% equ 0 (
|
||||||
|
echo [OK] Health checks passed
|
||||||
) else (
|
) else (
|
||||||
echo [FAIL] npm is not working correctly
|
echo [ERROR] Health checks failed (%HEALTH_ERRORS%)
|
||||||
set /a ERRORS+=1
|
set /a ERRORS+=%HEALTH_ERRORS%
|
||||||
)
|
|
||||||
|
|
||||||
:: 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 Run Launch-Windows.bat to start the application.
|
||||||
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo Press any key to exit...
|
echo Press any key to exit...
|
||||||
pause >nul
|
pause >nul
|
||||||
exit /b 0
|
exit /b %ERRORS%
|
||||||
)
|
|
||||||
|
|||||||
120
Launch-Dev-Unix.sh
Normal file
120
Launch-Dev-Unix.sh
Normal 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."
|
||||||
@@ -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%
|
||||||
|
|||||||
199
Launch-Unix.sh
199
Launch-Unix.sh
@@ -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 "[STEP 1/5] Checking Dependencies..."
|
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "NomadArch Launcher (macOS/Linux)"
|
||||||
|
echo "Version: 0.4.0"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[PREFLIGHT 1/7] Checking Dependencies..."
|
||||||
|
|
||||||
if ! command -v node &> /dev/null; then
|
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)"
|
||||||
|
((AUTO_FIXED++))
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[OK]${NC} Dependencies found"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "[STEP 4/5] Checking Port Availability..."
|
echo "[PREFLIGHT 4/7] Finding Available Port..."
|
||||||
|
|
||||||
|
DEFAULT_SERVER_PORT=3001
|
||||||
|
DEFAULT_UI_PORT=3000
|
||||||
|
SERVER_PORT=$DEFAULT_SERVER_PORT
|
||||||
|
UI_PORT=$DEFAULT_UI_PORT
|
||||||
|
|
||||||
|
for port in {3001..3050}; do
|
||||||
|
if ! lsof -i :$port -sTCP:LISTEN -t > /dev/null 2>&1; then
|
||||||
|
SERVER_PORT=$port
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "${GREEN}[OK]${NC} Server port: $SERVER_PORT"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "[PREFLIGHT 5/7] Final Checks..."
|
||||||
|
|
||||||
SERVER_PORT=3001
|
if [[ ! -d "packages/ui/dist" ]]; then
|
||||||
UI_PORT=3000
|
echo -e "${YELLOW}[WARN]${NC} UI build directory not found"
|
||||||
|
echo -e "${YELLOW}[INFO]${NC} Running UI build..."
|
||||||
if lsof -Pi :$SERVER_PORT -sTCP:LISTEN -t >/dev/null 2>&1; then
|
pushd packages/ui >/dev/null
|
||||||
echo "[WARN] Port $SERVER_PORT is already in use"
|
if ! npm run build; then
|
||||||
echo "[INFO] Another NomadArch instance or app may be running"
|
echo -e "${RED}[ERROR]${NC} UI build failed!"
|
||||||
echo "[INFO] To find the process: lsof -i :$SERVER_PORT"
|
popd >/dev/null
|
||||||
echo "[INFO] To kill it: kill -9 \$(lsof -t -i:$SERVER_PORT)"
|
((ERRORS++))
|
||||||
WARNINGS=$((WARNINGS + 1))
|
|
||||||
else
|
else
|
||||||
echo "[OK] Port $SERVER_PORT is available"
|
popd >/dev/null
|
||||||
|
echo -e "${GREEN}[OK]${NC} UI build completed (auto-fix)"
|
||||||
|
((AUTO_FIXED++))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[OK]${NC} UI build directory exists"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if lsof -Pi :$UI_PORT -sTCP:LISTEN -t >/dev/null 2>&1; then
|
if [[ ! -f "packages/electron-app/dist/main/main.js" ]]; then
|
||||||
echo "[WARN] Port $UI_PORT is already in use"
|
echo -e "${YELLOW}[WARN]${NC} Electron build incomplete"
|
||||||
echo "[INFO] To find the process: lsof -i :$UI_PORT"
|
echo -e "${YELLOW}[INFO]${NC} Running full build..."
|
||||||
echo "[INFO] To kill it: kill -9 \$(lsof -t -i:$UI_PORT)"
|
if ! npm run build; then
|
||||||
WARNINGS=$((WARNINGS + 1))
|
echo -e "${RED}[ERROR]${NC} Full build failed!"
|
||||||
|
((ERRORS++))
|
||||||
else
|
else
|
||||||
echo "[OK] Port $UI_PORT is available"
|
echo -e "${GREEN}[OK]${NC} Full build completed (auto-fix)"
|
||||||
|
((AUTO_FIXED++))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}[OK]${NC} Electron build exists"
|
||||||
fi
|
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
|
||||||
|
|||||||
@@ -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 ██║╚██╗██║██║ ██║██║╚██╔╝██║██╔══██║██║ ██║██╔══██║██╔══██╗██║ ██╔══██║
|
|
||||||
echo ██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝██║ ██║██║ ██║╚██████╗██║ ██║
|
|
||||||
echo ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
|
|
||||||
echo.
|
|
||||||
echo PRODUCTION LAUNCHER - Using Pre-Built Enhanced UI
|
|
||||||
echo ═════════════════════════════════════════════════════════════════════════════
|
|
||||||
echo.
|
|
||||||
echo Features: SMART FIX / APEX / SHIELD / MULTIX MODE
|
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%
|
||||||
|
|||||||
@@ -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.
|
echo.
|
||||||
cd packages\ui
|
echo [PREFLIGHT 4/7] Finding Available Port...
|
||||||
|
|
||||||
|
set DEFAULT_SERVER_PORT=3001
|
||||||
|
set DEFAULT_UI_PORT=3000
|
||||||
|
set SERVER_PORT=%DEFAULT_SERVER_PORT%
|
||||||
|
set UI_PORT=%DEFAULT_UI_PORT%
|
||||||
|
|
||||||
|
for /l %%p in (%DEFAULT_SERVER_PORT%,1,3050) do (
|
||||||
|
netstat -ano | findstr ":%%p " | findstr "LISTENING" >nul
|
||||||
|
if !ERRORLEVEL! neq 0 (
|
||||||
|
set SERVER_PORT=%%p
|
||||||
|
goto :server_port_found
|
||||||
|
)
|
||||||
|
)
|
||||||
|
:server_port_found
|
||||||
|
|
||||||
|
echo [OK] Server port: !SERVER_PORT!
|
||||||
|
|
||||||
|
if !SERVER_PORT! neq %DEFAULT_SERVER_PORT% (
|
||||||
|
echo [INFO] Port %DEFAULT_SERVER_PORT% was in use, using !SERVER_PORT! instead
|
||||||
|
set /a WARNINGS+=1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [PREFLIGHT 5/7] Final Checks...
|
||||||
|
|
||||||
|
if not exist "packages\ui\dist\index.html" (
|
||||||
|
echo [WARN] UI build directory not found
|
||||||
|
echo [INFO] Running UI build...
|
||||||
|
pushd packages\ui
|
||||||
call npm run build
|
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
|
||||||
|
) else (
|
||||||
|
echo [OK] UI build directory exists
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "packages\electron-app\dist\main\main.js" (
|
||||||
|
echo [WARN] Electron build incomplete
|
||||||
|
echo [INFO] Running full build...
|
||||||
|
call npm run build
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Full build failed!
|
||||||
|
set /a ERRORS+=1
|
||||||
|
goto :final_launch_check
|
||||||
|
)
|
||||||
|
echo [OK] Full build completed (auto-fix)
|
||||||
|
set /a AUTO_FIXED+=1
|
||||||
)
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo [STEP 4/5] Checking Port Availability...
|
echo [PREFLIGHT 6/7] Launch Summary
|
||||||
|
|
||||||
|
echo [STATUS]
|
||||||
echo.
|
echo.
|
||||||
|
echo Node.js: %NODE_VERSION%
|
||||||
set SERVER_PORT=3001
|
echo npm: %NPM_VERSION%
|
||||||
set UI_PORT=3000
|
echo Auto-fixes applied: !AUTO_FIXED!
|
||||||
|
echo Warnings: %WARNINGS%
|
||||||
netstat -ano | findstr ":%SERVER_PORT%" | findstr "LISTENING" >nul 2>&1
|
echo Errors: %ERRORS%
|
||||||
if %ERRORLEVEL% equ 0 (
|
echo Server Port: !SERVER_PORT!
|
||||||
echo [WARN] Port %SERVER_PORT% is already in use
|
|
||||||
echo [INFO] Another NomadArch instance or app may be running
|
|
||||||
echo [INFO] To find the process: netstat -ano | findstr ":%SERVER_PORT%"
|
|
||||||
echo [INFO] To kill it: taskkill /F /PID <PID>
|
|
||||||
set /a WARNINGS+=1
|
|
||||||
) else (
|
|
||||||
echo [OK] Port %SERVER_PORT% is available
|
|
||||||
)
|
|
||||||
|
|
||||||
netstat -ano | findstr ":%UI_PORT%" | findstr "LISTENING" >nul 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 (
|
|
||||||
echo [WARN] Port %UI_PORT% is already in use
|
|
||||||
echo [INFO] To find the process: netstat -ano | findstr ":%UI_PORT%"
|
|
||||||
echo [INFO] To kill it: taskkill /F /PID <PID>
|
|
||||||
set /a WARNINGS+=1
|
|
||||||
) else (
|
|
||||||
echo [OK] Port %UI_PORT% is available
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [STEP 5/5] Starting NomadArch...
|
|
||||||
echo.
|
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
1746
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
267
packages/electron-app/electron/main/user-store.ts
Normal file
267
packages/electron-app/electron/main/user-store.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -197,7 +356,6 @@ export class OllamaCloudClient {
|
|||||||
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",
|
||||||
@@ -271,3 +440,31 @@ export const DEFAULT_CLOUD_MODELS = [
|
|||||||
] 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]
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
index: z.number().optional(),
|
|
||||||
delta: z.object({
|
|
||||||
type: z.string().optional(),
|
|
||||||
text: z.string().optional()
|
|
||||||
}).optional(),
|
|
||||||
message: z.object({
|
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
type: z.string(),
|
object: z.string(),
|
||||||
role: z.string(),
|
created: z.number(),
|
||||||
content: z.array(z.any()),
|
model: z.string(),
|
||||||
model: z.string()
|
choices: z.array(z.object({
|
||||||
}).optional(),
|
index: z.number(),
|
||||||
content_block: z.object({
|
delta: z.object({
|
||||||
type: z.string(),
|
role: z.string().optional(),
|
||||||
text: z.string()
|
content: z.string().optional(),
|
||||||
}).optional()
|
reasoning_content: z.string().optional()
|
||||||
|
}),
|
||||||
|
finish_reason: z.string().nullable().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]
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,9 +34,9 @@ export async function registerOllamaRoutes(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update Ollama Cloud configuration
|
|
||||||
app.post('/api/ollama/config', {
|
app.post('/api/ollama/config', {
|
||||||
schema: {
|
schema: {
|
||||||
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['enabled'],
|
required: ['enabled'],
|
||||||
properties: {
|
properties: {
|
||||||
@@ -34,6 +45,7 @@ export async function registerOllamaRoutes(
|
|||||||
endpoint: { type: 'string' }
|
endpoint: { type: 'string' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { enabled, apiKey, endpoint } = request.body as any
|
const { enabled, apiKey, endpoint } = request.body as any
|
||||||
@@ -46,7 +58,6 @@ export async function registerOllamaRoutes(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test Ollama Cloud connection
|
|
||||||
app.post('/api/ollama/test', async (request, reply) => {
|
app.post('/api/ollama/test', async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const config = getOllamaConfig()
|
const config = getOllamaConfig()
|
||||||
@@ -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()
|
||||||
@@ -100,9 +112,60 @@ export async function registerOllamaRoutes(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Chat completion endpoint
|
app.get('/api/ollama/models/thinking', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const config = getOllamaConfig()
|
||||||
|
if (!config.enabled) {
|
||||||
|
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new OllamaCloudClient(config)
|
||||||
|
const thinkingModels = await client.getThinkingCapableModels()
|
||||||
|
|
||||||
|
return { models: thinkingModels }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to list thinking models")
|
||||||
|
return reply.status(500).send({ error: "Failed to list thinking models" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/ollama/models/vision', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const config = getOllamaConfig()
|
||||||
|
if (!config.enabled) {
|
||||||
|
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new OllamaCloudClient(config)
|
||||||
|
const visionModels = await client.getVisionCapableModels()
|
||||||
|
|
||||||
|
return { models: visionModels }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to list vision models")
|
||||||
|
return reply.status(500).send({ error: "Failed to list vision models" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/ollama/models/embedding', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const config = getOllamaConfig()
|
||||||
|
if (!config.enabled) {
|
||||||
|
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new OllamaCloudClient(config)
|
||||||
|
const embeddingModels = await client.getEmbeddingModels()
|
||||||
|
|
||||||
|
return { models: embeddingModels }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to list embedding models")
|
||||||
|
return reply.status(500).send({ error: "Failed to list embedding models" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/api/ollama/chat', {
|
app.post('/api/ollama/chat', {
|
||||||
schema: {
|
schema: {
|
||||||
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['model', 'messages'],
|
required: ['model', 'messages'],
|
||||||
properties: {
|
properties: {
|
||||||
@@ -119,6 +182,10 @@ export async function registerOllamaRoutes(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
stream: { type: 'boolean' },
|
stream: { type: 'boolean' },
|
||||||
|
think: { type: ['boolean', 'string'] },
|
||||||
|
format: { type: ['string', 'object'] },
|
||||||
|
tools: { type: 'array' },
|
||||||
|
web_search: { type: 'boolean' },
|
||||||
options: {
|
options: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -128,6 +195,7 @@ export async function registerOllamaRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const config = getOllamaConfig()
|
const config = getOllamaConfig()
|
||||||
@@ -138,7 +206,6 @@ 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',
|
||||||
@@ -159,13 +226,20 @@ export async function registerOllamaRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,15 +247,291 @@ export async function registerOllamaRoutes(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pull model endpoint
|
app.post('/api/ollama/chat/thinking', {
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['model', 'messages'],
|
||||||
|
properties: {
|
||||||
|
model: { type: 'string' },
|
||||||
|
messages: { type: 'array' },
|
||||||
|
stream: { type: 'boolean' },
|
||||||
|
think: { type: ['boolean', 'string'] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const config = getOllamaConfig()
|
||||||
|
if (!config.enabled) {
|
||||||
|
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new OllamaCloudClient(config)
|
||||||
|
const chatRequest = request.body as ChatRequest
|
||||||
|
chatRequest.think = chatRequest.think ?? true
|
||||||
|
|
||||||
|
if (chatRequest.stream) {
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await client.chatWithThinking(chatRequest)
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
|
|
||||||
|
if (chunk.done) {
|
||||||
|
reply.raw.write('data: [DONE]\n\n')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.raw.end()
|
||||||
|
} catch (streamError) {
|
||||||
|
logger.error({ error: streamError }, "Thinking streaming failed")
|
||||||
|
reply.raw.end()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const stream = await client.chatWithThinking(chatRequest)
|
||||||
|
const chunks: any[] = []
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
return chunks[chunks.length - 1]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Ollama thinking chat request failed")
|
||||||
|
return reply.status(500).send({ error: "Thinking chat request failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/ollama/chat/vision', {
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['model', 'messages', 'images'],
|
||||||
|
properties: {
|
||||||
|
model: { type: 'string' },
|
||||||
|
messages: { type: 'array' },
|
||||||
|
images: { type: 'array', items: { type: 'string' } },
|
||||||
|
stream: { type: 'boolean' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const config = getOllamaConfig()
|
||||||
|
if (!config.enabled) {
|
||||||
|
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new OllamaCloudClient(config)
|
||||||
|
const { model, messages, images, stream } = request.body as any
|
||||||
|
const chatRequest: ChatRequest = { model, messages, stream: stream ?? false }
|
||||||
|
|
||||||
|
if (chatRequest.stream) {
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamResult = await client.chatWithVision(chatRequest, images)
|
||||||
|
|
||||||
|
for await (const chunk of streamResult) {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
|
|
||||||
|
if (chunk.done) {
|
||||||
|
reply.raw.write('data: [DONE]\n\n')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.raw.end()
|
||||||
|
} catch (streamError) {
|
||||||
|
logger.error({ error: streamError }, "Vision streaming failed")
|
||||||
|
reply.raw.end()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const streamResult = await client.chatWithVision(chatRequest, images)
|
||||||
|
const chunks: any[] = []
|
||||||
|
for await (const chunk of streamResult) {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
return chunks[chunks.length - 1]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Ollama vision chat request failed")
|
||||||
|
return reply.status(500).send({ error: "Vision chat request failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/ollama/chat/tools', {
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['model', 'messages', 'tools'],
|
||||||
|
properties: {
|
||||||
|
model: { type: 'string' },
|
||||||
|
messages: { type: 'array' },
|
||||||
|
tools: { type: 'array' },
|
||||||
|
stream: { type: 'boolean' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const config = getOllamaConfig()
|
||||||
|
if (!config.enabled) {
|
||||||
|
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new OllamaCloudClient(config)
|
||||||
|
const { model, messages, tools, stream } = request.body as any
|
||||||
|
const chatRequest: ChatRequest = { model, messages, stream: stream ?? false }
|
||||||
|
|
||||||
|
if (chatRequest.stream) {
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamResult = await client.chatWithTools(chatRequest, tools)
|
||||||
|
|
||||||
|
for await (const chunk of streamResult) {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
|
|
||||||
|
if (chunk.done) {
|
||||||
|
reply.raw.write('data: [DONE]\n\n')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.raw.end()
|
||||||
|
} catch (streamError) {
|
||||||
|
logger.error({ error: streamError }, "Tools streaming failed")
|
||||||
|
reply.raw.end()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const streamResult = await client.chatWithTools(chatRequest, tools)
|
||||||
|
const chunks: any[] = []
|
||||||
|
for await (const chunk of streamResult) {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
return chunks[chunks.length - 1]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Ollama tools chat request failed")
|
||||||
|
return reply.status(500).send({ error: "Tools chat request failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/ollama/chat/websearch', {
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['model', 'messages'],
|
||||||
|
properties: {
|
||||||
|
model: { type: 'string' },
|
||||||
|
messages: { type: 'array' },
|
||||||
|
stream: { type: 'boolean' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const config = getOllamaConfig()
|
||||||
|
if (!config.enabled) {
|
||||||
|
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new OllamaCloudClient(config)
|
||||||
|
const chatRequest = request.body as ChatRequest
|
||||||
|
|
||||||
|
if (chatRequest.stream) {
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await client.chatWithWebSearch(chatRequest)
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
|
|
||||||
|
if (chunk.done) {
|
||||||
|
reply.raw.write('data: [DONE]\n\n')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.raw.end()
|
||||||
|
} catch (streamError) {
|
||||||
|
logger.error({ error: streamError }, "Web search streaming failed")
|
||||||
|
reply.raw.end()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const stream = await client.chatWithWebSearch(chatRequest)
|
||||||
|
const chunks: any[] = []
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
return chunks[chunks.length - 1]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Ollama web search chat request failed")
|
||||||
|
return reply.status(500).send({ error: "Web search chat request failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/ollama/embeddings', {
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['model', 'input'],
|
||||||
|
properties: {
|
||||||
|
model: { type: 'string' },
|
||||||
|
input: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const config = getOllamaConfig()
|
||||||
|
if (!config.enabled) {
|
||||||
|
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new OllamaCloudClient(config)
|
||||||
|
const embedRequest = request.body as EmbeddingRequest
|
||||||
|
|
||||||
|
const result = await client.generateEmbeddings(embedRequest)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Ollama embeddings request failed")
|
||||||
|
return reply.status(500).send({ error: "Embeddings request failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/api/ollama/pull', {
|
app.post('/api/ollama/pull', {
|
||||||
schema: {
|
schema: {
|
||||||
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['model'],
|
required: ['model'],
|
||||||
properties: {
|
properties: {
|
||||||
model: { type: 'string' }
|
model: { type: 'string' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const config = getOllamaConfig()
|
const config = getOllamaConfig()
|
||||||
@@ -192,7 +542,6 @@ 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")
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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: {
|
||||||
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['code', 'state'],
|
required: ['code_challenge', 'code_challenge_method'],
|
||||||
properties: {
|
properties: {
|
||||||
code: { type: 'string' },
|
code_challenge: { type: 'string' },
|
||||||
state: { type: 'string' },
|
code_challenge_method: { type: 'string' }
|
||||||
client_id: { 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 }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to request Qwen device authorization")
|
||||||
|
return reply.status(500).send({ error: "Device authorization failed" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Get user info
|
// Qwen OAuth Device Flow: poll token endpoint
|
||||||
const userResponse = await fetch('https://qwen.ai/api/user', {
|
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: {
|
headers: {
|
||||||
'Authorization': `Bearer ${tokenData.access_token}`
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
}
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: QWEN_OAUTH_DEVICE_GRANT_TYPE,
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||||
|
device_code,
|
||||||
|
code_verifier
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!userResponse.ok) {
|
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)
|
||||||
const userData = await userResponse.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
user: userData,
|
|
||||||
token: {
|
|
||||||
access_token: tokenData.access_token,
|
|
||||||
token_type: tokenData.token_type,
|
|
||||||
expires_in: tokenData.expires_in,
|
|
||||||
scope: tokenData.scope
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
return reply.send(JSON.parse(responseText))
|
||||||
|
} catch {
|
||||||
|
return reply.send(responseText)
|
||||||
}
|
}
|
||||||
} 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(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info("Qwen OAuth routes registered")
|
// Qwen Chat API - proxy chat requests to Qwen using OAuth token
|
||||||
|
app.post('/api/qwen/chat', {
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['model', 'messages'],
|
||||||
|
properties: {
|
||||||
|
model: { type: 'string' },
|
||||||
|
messages: { type: 'array' },
|
||||||
|
stream: { type: 'boolean' },
|
||||||
|
resource_url: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const authHeader = request.headers.authorization
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return reply.status(401).send({ error: "Authorization required" })
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateState(): string {
|
const accessToken = authHeader.substring(7)
|
||||||
return Math.random().toString(36).substring(2, 15) + Date.now().toString(36)
|
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")
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
28
packages/server/src/user-data.ts
Normal file
28
packages/server/src/user-data.ts
Normal 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")
|
||||||
|
}
|
||||||
35
packages/server/src/utils/port.ts
Normal file
35
packages/server/src/utils/port.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<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" />
|
<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,19 +793,21 @@ 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 */}
|
</div>
|
||||||
<Show when={isAgentThinking()}>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleStopAgent}
|
<Show when={attachments().length > 0}>
|
||||||
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"
|
<div class="flex flex-wrap gap-2 mb-2">
|
||||||
title="Stop agent"
|
<For each={attachments()}>
|
||||||
>
|
{(attachment) => (
|
||||||
<StopCircle size={10} />
|
<AttachmentChip
|
||||||
<span>STOP</span>
|
attachment={attachment}
|
||||||
</button>
|
onRemove={() => removeAttachment(attachment.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text Input */}
|
{/* Text Input */}
|
||||||
<textarea
|
<textarea
|
||||||
@@ -601,38 +824,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
|||||||
<div class="flex items-center justify-between pt-2 border-t border-white/5 mt-2">
|
<div class="flex items-center 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">
|
|
||||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">INPUT</span>
|
|
||||||
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().input.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-px h-3 bg-zinc-800" />
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">OUTPUT</span>
|
|
||||||
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().output.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={tokenStats().reasoning > 0}>
|
|
||||||
<div class="w-px h-3 bg-zinc-800" />
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">REASONING</span>
|
|
||||||
<span class="text-[9px] font-bold text-amber-400">{tokenStats().reasoning.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={tokenStats().cacheRead > 0}>
|
|
||||||
<div class="w-px h-3 bg-zinc-800" />
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE READ</span>
|
|
||||||
<span class="text-[9px] font-bold text-emerald-400">{tokenStats().cacheRead.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={tokenStats().cacheWrite > 0}>
|
|
||||||
<div class="w-px h-3 bg-zinc-800" />
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE WRITE</span>
|
|
||||||
<span class="text-[9px] font-bold text-cyan-400">{tokenStats().cacheWrite.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="w-px h-3 bg-zinc-800" />
|
|
||||||
<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">COST</span>
|
<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>
|
<span class="text-[9px] font-bold text-violet-400">${tokenStats().cost.toFixed(4)}</span>
|
||||||
@@ -642,8 +834,22 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
|||||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
|
<span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
|
||||||
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
|
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<div class="flex items-center space-x-1.5">
|
||||||
<Show when={!(tokenStats().input > 0 || tokenStats().output > 0)}>
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="sr-only"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef?.click()}
|
||||||
|
class="text-zinc-600 hover:text-indigo-300 transition-colors p-1"
|
||||||
|
title="Attach files"
|
||||||
|
>
|
||||||
|
<Paperclip size={14} />
|
||||||
|
</button>
|
||||||
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
|
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
|
||||||
<Hash size={14} />
|
<Hash size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -655,9 +861,20 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
|||||||
<kbd class="px-1.5 py-0.5 bg-zinc-800 rounded text-[9px] font-bold border border-white/5">ENTER</kbd>
|
<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>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Show when={isAgentThinking() || isSending()}>
|
||||||
|
<button
|
||||||
|
onClick={handleStopAgent}
|
||||||
|
class="px-3 py-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 rounded-lg text-[10px] font-bold uppercase tracking-wide transition-all border border-rose-500/30"
|
||||||
|
title="Stop response"
|
||||||
|
>
|
||||||
|
<StopCircle size={12} class="inline-block mr-1" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<button
|
<button
|
||||||
onClick={handleSendMessage}
|
onClick={handleSendMessage}
|
||||||
disabled={!chatInput().trim() || isSending()}
|
disabled={!chatInput().trim() || isSending()}
|
||||||
@@ -676,33 +893,41 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
<p class="text-[11px] text-zinc-300 leading-relaxed line-clamp-4">
|
<div class="text-[8px] text-zinc-600">
|
||||||
|
{msg()?.status === "streaming" ? "• Streaming" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-zinc-300 leading-relaxed whitespace-pre-wrap">
|
||||||
{previewText()}
|
{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]">
|
|
||||||
{/* 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 >
|
</main >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +25,12 @@ 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
|
||||||
|
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
@@ -202,6 +210,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
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
|
||||||
@@ -244,9 +292,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</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>
|
</div>
|
||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">NomadArch</h1>
|
||||||
|
<p class="text-xs text-muted mb-1">Forked from OpenCode</p>
|
||||||
|
<Show when={activeUser()}>
|
||||||
|
{(user) => (
|
||||||
|
<p class="text-xs text-muted mb-1">
|
||||||
|
Active user: <span class="text-secondary font-medium">{user().name}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,9 +144,15 @@ 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
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshRootFiles = async () => {
|
||||||
|
if (!props.instanceId) return
|
||||||
try {
|
try {
|
||||||
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
|
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
|
||||||
setRootFiles(entries.map(e => ({
|
setRootFiles(entries.map(e => ({
|
||||||
@@ -155,6 +164,20 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
|||||||
console.error("Failed to load root files", 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">
|
||||||
|
|||||||
501
packages/ui/src/components/mcp-manager.tsx
Normal file
501
packages/ui/src/components/mcp-manager.tsx
Normal 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'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
|
||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
@@ -76,7 +101,7 @@ const OllamaCloudSettings: Component = () => {
|
|||||||
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'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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 */}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
BIN
packages/ui/src/images/NomadArch-Icon.png
Normal file
BIN
packages/ui/src/images/NomadArch-Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 471 KiB |
@@ -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 {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
390
packages/ui/src/lib/__tests__/compaction-schema.test.ts
Normal file
390
packages/ui/src/lib/__tests__/compaction-schema.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
158
packages/ui/src/lib/__tests__/secrets-detector.test.ts
Normal file
158
packages/ui/src/lib/__tests__/secrets-detector.test.ts
Normal 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"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
|||||||
168
packages/ui/src/lib/compaction-schema.ts
Normal file
168
packages/ui/src/lib/compaction-schema.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`, {
|
||||||
|
|||||||
286
packages/ui/src/lib/integrations/qwen-chat.ts
Normal file
286
packages/ui/src/lib/integrations/qwen-chat.ts
Normal 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' }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
code_challenge_method: 'S256'
|
|
||||||
})
|
|
||||||
|
|
||||||
const authUrl = `${this.config.baseUrl}/oauth/authorize?${params.toString()}`
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: authUrl,
|
|
||||||
state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchange authorization code for access token
|
|
||||||
*/
|
|
||||||
async exchangeCodeForToken(code: string, state: string): Promise<QwenAuthToken> {
|
|
||||||
const storedState = this.getOAuthState(state)
|
|
||||||
if (!storedState) {
|
|
||||||
throw new Error('Invalid OAuth state')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
|
|
||||||
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: 'authorization_code',
|
code_challenge: codeChallenge,
|
||||||
client_id: this.config.clientId,
|
code_challenge_method: 'S256'
|
||||||
code,
|
|
||||||
redirect_uri: this.config.redirectUri,
|
|
||||||
code_verifier: storedState.code_verifier
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Token exchange failed: ${response.statusText}`)
|
const message = await response.text()
|
||||||
|
throw new Error(`Device authorization failed: ${message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = await response.json()
|
return await response.json()
|
||||||
const token = this.parseTokenResponse(tokenData)
|
}
|
||||||
|
|
||||||
// Store token
|
/**
|
||||||
this.storeToken(token)
|
* Poll device token endpoint
|
||||||
this.clearOAuthState(state)
|
*/
|
||||||
|
async pollDeviceToken(deviceCode: string, codeVerifier: string): Promise<any> {
|
||||||
|
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
device_code: deviceCode,
|
||||||
|
code_verifier: codeVerifier
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return token
|
const rawText = await response.text()
|
||||||
} catch (error) {
|
try {
|
||||||
this.clearOAuthState(state)
|
return JSON.parse(rawText)
|
||||||
throw error
|
} catch {
|
||||||
|
throw new Error(`Token poll failed: ${rawText}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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 {
|
||||||
|
const response = await fetch(`/api/qwen/user`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token.access_token}`
|
'Authorization': `Bearer ${token.access_token}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch user info: ${response.statusText}`)
|
throw new Error(`Failed to fetch user info: ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
const data = await response.json()
|
||||||
return await response.json()
|
return data.user || data
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
id: 'qwen-oauth',
|
||||||
|
username: 'Qwen OAuth',
|
||||||
|
tier: 'Free',
|
||||||
|
limits: {
|
||||||
|
requests_per_day: 0,
|
||||||
|
requests_per_minute: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,11 +197,7 @@ export class QwenOAuthManager {
|
|||||||
return null
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
const deviceAuth = await manager.requestDeviceAuthorization(codeChallenge)
|
||||||
|
|
||||||
// Open popup window for OAuth
|
|
||||||
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 (event.data.type === 'QWEN_OAUTH_SUCCESS') {
|
if (tokenData?.access_token) {
|
||||||
const { code, state } = event.data
|
const token = manager.parseTokenResponse(tokenData)
|
||||||
await manager.exchangeCodeForToken(code, state)
|
manager.storeToken(token)
|
||||||
|
setTokenInfo(manager.getTokenInfo())
|
||||||
const userInfo = await manager.fetchUserInfo()
|
const userInfo = await manager.fetchUserInfo()
|
||||||
|
if (userInfo) {
|
||||||
|
manager.storeUserInfo(userInfo)
|
||||||
setUser(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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('message', messageListener)
|
if (tokenData?.error === 'authorization_pending') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup
|
if (tokenData?.error === 'slow_down') {
|
||||||
setTimeout(() => {
|
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), 10000)
|
||||||
clearInterval(checkClosed)
|
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
||||||
window.removeEventListener('message', messageListener)
|
continue
|
||||||
setIsLoading(false)
|
}
|
||||||
}, 300000) // 5 minute timeout
|
|
||||||
|
throw new Error(tokenData?.error_description || tokenData?.error || 'OAuth failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('OAuth timed out')
|
||||||
|
|
||||||
} 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
225
packages/ui/src/lib/secrets-detector.ts
Normal file
225
packages/ui/src/lib/secrets-detector.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
7
packages/ui/src/lib/user-storage.ts
Normal file
7
packages/ui/src/lib/user-storage.ts
Normal 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}`
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CodeNomad</title>
|
<title>NomadArch</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
@@ -25,8 +27,10 @@
|
|||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="./main.tsx"></script>
|
<script type="module" src="./main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
273
packages/ui/src/stores/__tests__/session-compaction.test.ts
Normal file
273
packages/ui/src/stores/__tests__/session-compaction.test.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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] : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a context length error
|
||||||
|
if (isContextLengthError(error)) {
|
||||||
|
if (sessionId && consumeCompactionSuppression(instanceId, sessionId)) {
|
||||||
|
showAlertDialog("Compaction failed because the model context limit was exceeded. Reduce context or switch to a larger context model, then try compact again.", {
|
||||||
|
title: "Compaction failed",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (sessionId && consumeTokenWarningSuppression(instanceId, sessionId)) {
|
||||||
|
showToastNotification({
|
||||||
|
title: "Context limit exceeded",
|
||||||
|
message: "Compaction is required before continuing.",
|
||||||
|
variant: "warning",
|
||||||
|
duration: 7000,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Context length error detected; suggesting compaction", { instanceId, sessionId })
|
||||||
|
if (sessionId) {
|
||||||
|
setCompactionSuggestion(instanceId, sessionId, "Context limit exceeded. Compact to continue.")
|
||||||
|
showToastNotification({
|
||||||
|
title: "Compaction required",
|
||||||
|
message: "Click Compact to continue this session.",
|
||||||
|
variant: "warning",
|
||||||
|
duration: 8000,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
showAlertDialog(`Error: ${message}`, {
|
showAlertDialog(`Error: ${message}`, {
|
||||||
title: "Session error",
|
title: "Session error",
|
||||||
variant: "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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
89
packages/ui/src/stores/users.ts
Normal file
89
packages/ui/src/stores/users.ts
Normal 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,
|
||||||
|
}
|
||||||
95
packages/ui/src/styles/components/mcp-manager.css
Normal file
95
packages/ui/src/styles/components/mcp-manager.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
191
packages/ui/src/styles/responsive.css
Normal file
191
packages/ui/src/styles/responsive.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -236,23 +236,6 @@
|
|||||||
--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);
|
||||||
--session-tab-hover-bg: #3f3f46;
|
|
||||||
|
|
||||||
--button-primary-bg: #3f3f46;
|
|
||||||
--button-primary-hover-bg: #52525b;
|
|
||||||
--button-primary-text: #f5f6f8;
|
|
||||||
--tab-active-bg: #3f3f46;
|
|
||||||
--tab-active-hover-bg: #52525b;
|
|
||||||
--tab-active-text: #f5f6f8;
|
|
||||||
--tab-inactive-bg: #2f2f36;
|
|
||||||
--tab-inactive-hover-bg: #3d3d45;
|
|
||||||
--tab-inactive-text: #d4d4d8;
|
|
||||||
--new-tab-bg: #3f3f46;
|
|
||||||
--new-tab-hover-bg: #52525b;
|
|
||||||
--new-tab-text: #f5f6f8;
|
|
||||||
--session-tab-active-bg: var(--surface-muted);
|
|
||||||
--session-tab-active-text: var(--text-primary);
|
|
||||||
--session-tab-inactive-text: var(--text-muted);
|
|
||||||
--session-tab-hover-bg: #3f3f46;
|
--session-tab-hover-bg: #3f3f46;
|
||||||
|
|
||||||
/* Layout & spacing tokens */
|
/* Layout & spacing tokens */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
7
packages/ui/src/types/global.d.ts
vendored
7
packages/ui/src/types/global.d.ts
vendored
@@ -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 {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
54
scripts/check-port.js
Normal 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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user