v0.5.0: NomadArch - Binary-Free Mode Release
Some checks failed
Release Binaries / release (push) Has been cancelled
Some checks failed
Release Binaries / release (push) Has been cancelled
Features: - Binary-Free Mode: No OpenCode binary required - NomadArch Native mode with free Zen models - Native session management - Provider routing (Zen, Qwen, Z.AI) - Fixed MCP connection with explicit connectAll() - Updated installers and launchers for all platforms - UI binary selector with Native option Free Models Available: - GPT-5 Nano (400K context) - Grok Code Fast 1 (256K context) - GLM-4.7 (205K context) - Doubao Seed Code (256K context) - Big Pickle (200K context)
This commit is contained in:
4
packages/electron-app/.gitignore
vendored
Normal file
4
packages/electron-app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
40
packages/electron-app/README.md
Normal file
40
packages/electron-app/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# CodeNomad App
|
||||
|
||||
This package contains the native desktop application shell for CodeNomad, built with [Electron](https://www.electronjs.org/).
|
||||
|
||||
## Overview
|
||||
|
||||
The Electron app wraps the CodeNomad UI and Server into a standalone executable. It provides deeper system integration, such as:
|
||||
- Native window management
|
||||
- Global keyboard shortcuts
|
||||
- Application menu integration
|
||||
|
||||
## Development
|
||||
|
||||
To run the Electron app in development mode:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will start the renderer (UI) and the main process with hot reloading.
|
||||
|
||||
## Building
|
||||
|
||||
To build the application for your current platform:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
To build for specific platforms (requires appropriate build tools):
|
||||
|
||||
- **macOS**: `npm run build:mac`
|
||||
- **Windows**: `npm run build:win`
|
||||
- **Linux**: `npm run build:linux`
|
||||
|
||||
## Structure
|
||||
|
||||
- `electron/main`: Main process code (window creation, IPC).
|
||||
- `electron/preload`: Preload scripts for secure bridge between main and renderer.
|
||||
- `electron/resources`: Static assets like icons.
|
||||
72
packages/electron-app/electron.vite.config.ts
Normal file
72
packages/electron-app/electron.vite.config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { resolve } from "path"
|
||||
|
||||
const uiRoot = resolve(__dirname, "../ui")
|
||||
const uiSrc = resolve(uiRoot, "src")
|
||||
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: "dist/main",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/main/main.ts"),
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: "dist/preload",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/preload/index.cjs"),
|
||||
formats: ["cjs"],
|
||||
fileName: () => "index.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
output: {
|
||||
entryFileNames: "index.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: uiRendererRoot,
|
||||
plugins: [solid()],
|
||||
css: {
|
||||
postcss: resolve(uiRoot, "postcss.config.js"),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": uiSrc,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
minify: false,
|
||||
cssMinify: false,
|
||||
sourcemap: true,
|
||||
outDir: resolve(__dirname, "dist/renderer"),
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: uiRendererEntry,
|
||||
loading: uiRendererLoadingEntry,
|
||||
},
|
||||
output: {
|
||||
compact: false,
|
||||
minifyInternalExports: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
112
packages/electron-app/electron/main/ipc.ts
Normal file
112
packages/electron-app/electron/main/ipc.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
||||
import path from "path"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
import {
|
||||
listUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
verifyPassword,
|
||||
setActiveUser,
|
||||
createGuestUser,
|
||||
getActiveUser,
|
||||
getUserDataRoot,
|
||||
} from "./user-store"
|
||||
|
||||
interface DialogOpenRequest {
|
||||
mode: "directory" | "file"
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: Array<{ name?: string; extensions: string[] }>
|
||||
}
|
||||
|
||||
interface DialogOpenResult {
|
||||
canceled: boolean
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||
cliManager.on("status", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:status", status)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("ready", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:ready", status)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("error", (error: Error) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:error", { message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
|
||||
|
||||
ipcMain.handle("cli:restart", async () => {
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
await cliManager.stop()
|
||||
return cliManager.start({ dev: devMode })
|
||||
})
|
||||
|
||||
ipcMain.handle("users:list", async () => listUsers())
|
||||
ipcMain.handle("users:active", async () => getActiveUser())
|
||||
ipcMain.handle("users:create", async (_, payload: { name: string; password: string }) => {
|
||||
const user = createUser(payload.name, payload.password)
|
||||
return user
|
||||
})
|
||||
ipcMain.handle("users:update", async (_, payload: { id: string; name?: string; password?: string }) => {
|
||||
const user = updateUser(payload.id, { name: payload.name, password: payload.password })
|
||||
return user
|
||||
})
|
||||
ipcMain.handle("users:delete", async (_, payload: { id: string }) => {
|
||||
deleteUser(payload.id)
|
||||
return { success: true }
|
||||
})
|
||||
ipcMain.handle("users:createGuest", async () => {
|
||||
const user = createGuestUser()
|
||||
return user
|
||||
})
|
||||
ipcMain.handle("users:login", async (_, payload: { id: string; password?: string }) => {
|
||||
const ok = verifyPassword(payload.id, payload.password ?? "")
|
||||
if (!ok) {
|
||||
return { success: false }
|
||||
}
|
||||
const user = setActiveUser(payload.id)
|
||||
const root = getUserDataRoot(user.id)
|
||||
cliManager.setUserEnv({
|
||||
CODENOMAD_USER_DIR: root,
|
||||
CLI_CONFIG: path.join(root, "config.json"),
|
||||
})
|
||||
await cliManager.stop()
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
await cliManager.start({ dev: devMode })
|
||||
return { success: true, user }
|
||||
})
|
||||
|
||||
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
||||
const properties: OpenDialogOptions["properties"] =
|
||||
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
|
||||
|
||||
const filters = request.filters?.map((filter) => ({
|
||||
name: filter.name ?? "Files",
|
||||
extensions: filter.extensions,
|
||||
}))
|
||||
|
||||
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
|
||||
const dialogOptions: OpenDialogOptions = {
|
||||
title: request.title,
|
||||
defaultPath: request.defaultPath,
|
||||
properties,
|
||||
filters,
|
||||
}
|
||||
const result = windowTarget
|
||||
? await dialog.showOpenDialog(windowTarget, dialogOptions)
|
||||
: await dialog.showOpenDialog(dialogOptions)
|
||||
|
||||
return { canceled: result.canceled, paths: result.filePaths }
|
||||
})
|
||||
}
|
||||
522
packages/electron-app/electron/main/main.ts
Normal file
522
packages/electron-app/electron/main/main.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||
import { existsSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
import { ensureDefaultUsers, getActiveUser, getUserDataRoot, clearGuestUsers } from "./user-store"
|
||||
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = dirname(mainFilename)
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
const cliManager = new CliProcessManager()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentCliUrl: string | null = null
|
||||
let pendingCliUrl: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
|
||||
// Retry logic constants
|
||||
const MAX_RETRY_ATTEMPTS = 5
|
||||
const LOAD_TIMEOUT_MS = 30000
|
||||
let retryAttempts = 0
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
}
|
||||
|
||||
// Windows: Use Edge WebView2 rendering for better performance
|
||||
if (process.platform === "win32") {
|
||||
app.commandLine.appendSwitch("enable-features", "WebViewTagWebComponent,WebView2")
|
||||
app.commandLine.appendSwitch("disable-gpu-sandbox")
|
||||
app.commandLine.appendSwitch("enable-gpu-rasterization")
|
||||
app.commandLine.appendSwitch("enable-zero-copy")
|
||||
app.commandLine.appendSwitch("disable-background-timer-throttling")
|
||||
app.commandLine.appendSwitch("disable-renderer-backgrounding")
|
||||
}
|
||||
|
||||
function getIconPath() {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, "icon.png")
|
||||
}
|
||||
|
||||
return join(mainDirname, "../resources/icon.png")
|
||||
}
|
||||
|
||||
type LoadingTarget =
|
||||
| { type: "url"; source: string }
|
||||
| { type: "file"; source: string }
|
||||
|
||||
function resolveDevLoadingUrl(): string | null {
|
||||
if (app.isPackaged) {
|
||||
return null
|
||||
}
|
||||
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
|
||||
if (!devBase) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
|
||||
return new URL("loading.html", normalized).toString()
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to construct dev loading URL", devBase, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLoadingTarget(): LoadingTarget {
|
||||
const devUrl = resolveDevLoadingUrl()
|
||||
if (devUrl) {
|
||||
return { type: "url", source: devUrl }
|
||||
}
|
||||
const filePath = resolveLoadingFilePath()
|
||||
return { type: "file", source: filePath }
|
||||
}
|
||||
|
||||
function resolveLoadingFilePath() {
|
||||
const candidates = [
|
||||
join(app.getAppPath(), "dist/renderer/loading.html"),
|
||||
join(process.resourcesPath, "dist/renderer/loading.html"),
|
||||
join(mainDirname, "../dist/renderer/loading.html"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return join(app.getAppPath(), "dist/renderer/loading.html")
|
||||
}
|
||||
|
||||
function loadLoadingScreen(window: BrowserWindow) {
|
||||
const target = resolveLoadingTarget()
|
||||
const loader =
|
||||
target.type === "url"
|
||||
? window.loadURL(target.source)
|
||||
: window.loadFile(target.source)
|
||||
|
||||
loader.catch((error) => {
|
||||
console.error("[cli] failed to load loading screen:", error)
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate exponential backoff delay
|
||||
function getRetryDelay(attempt: number): number {
|
||||
return Math.min(1000 * Math.pow(2, attempt), 16000) // 1s, 2s, 4s, 8s, 16s max
|
||||
}
|
||||
|
||||
// Show user-friendly error screen
|
||||
function showErrorScreen(window: BrowserWindow, errorMessage: string) {
|
||||
const errorHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.error-icon { font-size: 48px; margin-bottom: 20px; }
|
||||
h1 { margin: 0 0 16px; font-size: 24px; font-weight: 600; }
|
||||
p { margin: 0 0 24px; color: #888; font-size: 14px; text-align: center; max-width: 400px; }
|
||||
.error-code { font-family: monospace; background: #2a2a2a; padding: 8px 16px; border-radius: 6px; font-size: 12px; color: #f87171; margin-bottom: 24px; }
|
||||
button {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
button:hover { background: #818cf8; transform: scale(1.02); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h1>Connection Failed</h1>
|
||||
<p>NomadArch couldn't connect to the development server after multiple attempts. Please ensure the server is running.</p>
|
||||
<div class="error-code">${errorMessage}</div>
|
||||
<button onclick="location.reload()">Retry</button>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(errorHtml)}`)
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(): string[] {
|
||||
const origins = new Set<string>()
|
||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||
for (const candidate of rendererCandidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
origins.add(new URL(candidate).origin)
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to parse origin for", candidate, error)
|
||||
}
|
||||
}
|
||||
return Array.from(origins)
|
||||
}
|
||||
|
||||
function shouldOpenExternally(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return true
|
||||
}
|
||||
const allowedOrigins = getAllowedRendererOrigins()
|
||||
return !allowedOrigins.includes(parsed.origin)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setupNavigationGuards(window: BrowserWindow) {
|
||||
const handleExternal = (url: string) => {
|
||||
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
return { action: "allow" }
|
||||
})
|
||||
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
event.preventDefault()
|
||||
handleExternal(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
return cachedPreloadPath
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
join(process.resourcesPath, "preload/index.js"),
|
||||
join(mainDirname, "../preload/index.js"),
|
||||
join(mainDirname, "../preload/index.cjs"),
|
||||
join(mainDirname, "../../preload/index.cjs"),
|
||||
join(mainDirname, "../../electron/preload/index.cjs"),
|
||||
join(app.getAppPath(), "preload/index.cjs"),
|
||||
join(app.getAppPath(), "electron/preload/index.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
cachedPreloadPath = candidate
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return join(mainDirname, "../preload/index.js")
|
||||
}
|
||||
|
||||
function applyUserEnvToCli() {
|
||||
const active = getActiveUser()
|
||||
if (!active) {
|
||||
const fallback = ensureDefaultUsers()
|
||||
const fallbackRoot = getUserDataRoot(fallback.id)
|
||||
cliManager.setUserEnv({
|
||||
CODENOMAD_USER_DIR: fallbackRoot,
|
||||
CLI_CONFIG: join(fallbackRoot, "config.json"),
|
||||
})
|
||||
return
|
||||
}
|
||||
const root = getUserDataRoot(active.id)
|
||||
cliManager.setUserEnv({
|
||||
CODENOMAD_USER_DIR: root,
|
||||
CLI_CONFIG: join(root, "config.json"),
|
||||
})
|
||||
}
|
||||
|
||||
function destroyPreloadingView(target?: BrowserView | null) {
|
||||
const view = target ?? preloadingView
|
||||
if (!view) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = view.webContents as any
|
||||
contents?.destroy?.()
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to destroy preloading view", error)
|
||||
}
|
||||
|
||||
if (!target || view === preloadingView) {
|
||||
preloadingView = null
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const prefersDark = true
|
||||
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
||||
const iconPath = getIconPath()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor,
|
||||
icon: iconPath,
|
||||
title: "NomadArch 1.0",
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
setupNavigationGuards(mainWindow)
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
|
||||
if (process.env.NODE_ENV === "development" && process.env.NOMADARCH_OPEN_DEVTOOLS === "true") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupCliIPC(mainWindow, cliManager)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
destroyPreloadingView()
|
||||
mainWindow = null
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
showingLoadingScreen = false
|
||||
})
|
||||
|
||||
if (pendingCliUrl) {
|
||||
const url = pendingCliUrl
|
||||
pendingCliUrl = null
|
||||
startCliPreload(url)
|
||||
}
|
||||
}
|
||||
|
||||
function showLoadingScreen(force = false) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (showingLoadingScreen && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
destroyPreloadingView()
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
}
|
||||
|
||||
function startCliPreload(url: string) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
return
|
||||
}
|
||||
|
||||
if (currentCliUrl === url && !showingLoadingScreen) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingCliUrl = url
|
||||
destroyPreloadingView()
|
||||
|
||||
if (!showingLoadingScreen) {
|
||||
showLoadingScreen(true)
|
||||
}
|
||||
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
preloadingView = view
|
||||
|
||||
view.webContents.once("did-finish-load", () => {
|
||||
if (preloadingView !== view) {
|
||||
destroyPreloadingView(view)
|
||||
return
|
||||
}
|
||||
finalizeCliSwap(url)
|
||||
})
|
||||
|
||||
view.webContents.loadURL(url).catch((error) => {
|
||||
console.error("[cli] failed to preload CLI view:", error)
|
||||
if (preloadingView === view) {
|
||||
destroyPreloadingView(view)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function finalizeCliSwap(url: string) {
|
||||
destroyPreloadingView()
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
return
|
||||
}
|
||||
|
||||
showingLoadingScreen = false
|
||||
currentCliUrl = url
|
||||
pendingCliUrl = null
|
||||
|
||||
// Reset retry counter on new URL
|
||||
retryAttempts = 0
|
||||
|
||||
const loadWithRetry = () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
|
||||
// Set timeout for load
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.warn(`[cli] Load timeout after ${LOAD_TIMEOUT_MS}ms`)
|
||||
handleLoadError(new Error(`Load timeout after ${LOAD_TIMEOUT_MS}ms`))
|
||||
}, LOAD_TIMEOUT_MS)
|
||||
|
||||
mainWindow.loadURL(url)
|
||||
.then(() => {
|
||||
clearTimeout(timeoutId)
|
||||
retryAttempts = 0 // Reset on success
|
||||
console.info("[cli] Successfully loaded CLI view")
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId)
|
||||
handleLoadError(error)
|
||||
})
|
||||
}
|
||||
|
||||
const handleLoadError = (error: Error) => {
|
||||
const errorCode = (error as any).errno
|
||||
console.error(`[cli] failed to load CLI view (attempt ${retryAttempts + 1}/${MAX_RETRY_ATTEMPTS}):`, error.message)
|
||||
|
||||
// Retry on network errors (errno -3)
|
||||
if (errorCode === -3 && retryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||
retryAttempts++
|
||||
const delay = getRetryDelay(retryAttempts)
|
||||
console.info(`[cli] Retrying in ${delay}ms (attempt ${retryAttempts}/${MAX_RETRY_ATTEMPTS})`)
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
loadLoadingScreen(mainWindow)
|
||||
}
|
||||
|
||||
setTimeout(loadWithRetry, delay)
|
||||
} else if (retryAttempts >= MAX_RETRY_ATTEMPTS) {
|
||||
console.error("[cli] Max retry attempts reached, showing error screen")
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
showErrorScreen(mainWindow, `Failed after ${MAX_RETRY_ATTEMPTS} attempts: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadWithRetry()
|
||||
}
|
||||
|
||||
|
||||
async function startCli() {
|
||||
try {
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||
await cliManager.start({ dev: devMode })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error("[cli] start failed:", message)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:error", { message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cliManager.on("ready", (status) => {
|
||||
if (!status.url) {
|
||||
return
|
||||
}
|
||||
startCliPreload(status.url)
|
||||
})
|
||||
|
||||
cliManager.on("status", (status) => {
|
||||
if (status.state !== "ready") {
|
||||
showLoadingScreen()
|
||||
}
|
||||
})
|
||||
|
||||
if (isMac) {
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
ensureDefaultUsers()
|
||||
applyUserEnvToCli()
|
||||
startCli()
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
|
||||
if (app.dock) {
|
||||
const dockIcon = nativeImage.createFromPath(getIconPath())
|
||||
if (!dockIcon.isEmpty()) {
|
||||
app.dock.setIcon(dockIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await cliManager.stop().catch(() => { })
|
||||
clearGuestUsers()
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
84
packages/electron-app/electron/main/menu.ts
Normal file
84
packages/electron-app/electron/main/menu.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
||||
|
||||
export function createApplicationMenu(mainWindow: BrowserWindow) {
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: "CodeNomad",
|
||||
submenu: [
|
||||
{ role: "about" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "hide" as const },
|
||||
{ role: "hideOthers" as const },
|
||||
{ role: "unhide" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "quit" as const },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "New Instance",
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click: () => {
|
||||
mainWindow.webContents.send("menu:newInstance")
|
||||
},
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
isMac ? { role: "close" as const } : { role: "quit" as const },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" as const },
|
||||
{ role: "redo" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "cut" as const },
|
||||
{ role: "copy" as const },
|
||||
{ role: "paste" as const },
|
||||
...(isMac
|
||||
? [{ role: "pasteAndMatchStyle" as const }, { role: "delete" as const }, { role: "selectAll" as const }]
|
||||
: [{ role: "delete" as const }, { type: "separator" as const }, { role: "selectAll" as const }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" as const },
|
||||
{ role: "forceReload" as const },
|
||||
{ role: "toggleDevTools" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "resetZoom" as const },
|
||||
{ role: "zoomIn" as const },
|
||||
{ role: "zoomOut" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "togglefullscreen" as const },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
submenu: [
|
||||
{ role: "minimize" as const },
|
||||
{ role: "zoom" as const },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: "separator" as const },
|
||||
{ role: "front" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "window" as const },
|
||||
]
|
||||
: [{ role: "close" as const }]),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
371
packages/electron-app/electron/main/process-manager.ts
Normal file
371
packages/electron-app/electron/main/process-manager.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
|
||||
export interface CliStatus {
|
||||
state: CliState
|
||||
pid?: number
|
||||
port?: number
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CliLogEntry {
|
||||
stream: "stdout" | "stderr"
|
||||
message: string
|
||||
}
|
||||
|
||||
interface StartOptions {
|
||||
dev: boolean
|
||||
}
|
||||
|
||||
interface CliEntryResolution {
|
||||
entry: string
|
||||
runner: "node" | "tsx"
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function resolveConfigPath(configPath?: string): string {
|
||||
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||
if (target.startsWith("~/")) {
|
||||
return path.join(os.homedir(), target.slice(2))
|
||||
}
|
||||
return path.resolve(target)
|
||||
}
|
||||
|
||||
function resolveHostForMode(mode: ListeningMode): string {
|
||||
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
|
||||
}
|
||||
|
||||
function readListeningModeFromConfig(): ListeningMode {
|
||||
try {
|
||||
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
|
||||
if (!existsSync(configPath)) return "local"
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const mode = parsed?.preferences?.listeningMode
|
||||
if (mode === "local" || mode === "all") {
|
||||
return mode
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to read listening mode from config", error)
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
export declare interface CliProcessManager {
|
||||
on(event: "status", listener: (status: CliStatus) => void): this
|
||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||
on(event: "error", listener: (error: Error) => void): this
|
||||
}
|
||||
|
||||
export class CliProcessManager extends EventEmitter {
|
||||
private child?: ChildProcess
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private userEnv: Record<string, string> = {}
|
||||
|
||||
setUserEnv(env: Record<string, string>) {
|
||||
this.userEnv = { ...env }
|
||||
}
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
await this.stop()
|
||||
}
|
||||
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const listeningMode = this.resolveListeningMode()
|
||||
const host = resolveHostForMode(listeningMode)
|
||||
const args = this.buildCliArgs(options, host)
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
Object.assign(env, this.userEnv)
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
if (!child.pid) {
|
||||
console.error("[cli] spawn failed: no pid")
|
||||
}
|
||||
|
||||
this.child = child
|
||||
this.updateStatus({ pid: child.pid ?? undefined })
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
this.handleStream(data.toString(), "stdout")
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
this.handleStream(data.toString(), "stderr")
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
|
||||
return new Promise<CliStatus>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.handleTimeout()
|
||||
reject(new Error("CLI startup timeout"))
|
||||
}, 60000)
|
||||
|
||||
this.once("ready", (status) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(status)
|
||||
})
|
||||
|
||||
this.once("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const child = this.child
|
||||
if (!child) {
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
}, 4000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
console.info("[cli] CLI process exited")
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.kill("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(): CliStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
this.child.kill("SIGKILL")
|
||||
this.child = undefined
|
||||
}
|
||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||
this.emit("error", new Error("CLI did not start in time"))
|
||||
}
|
||||
|
||||
private handleStream(chunk: string, stream: "stdout" | "stderr") {
|
||||
if (stream === "stdout") {
|
||||
this.stdoutBuffer += chunk
|
||||
this.processBuffer("stdout")
|
||||
} else {
|
||||
this.stderrBuffer += chunk
|
||||
this.processBuffer("stderr")
|
||||
}
|
||||
}
|
||||
|
||||
private processBuffer(stream: "stdout" | "stderr") {
|
||||
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
|
||||
const lines = buffer.split("\n")
|
||||
const trailing = lines.pop() ?? ""
|
||||
|
||||
if (stream === "stdout") {
|
||||
this.stdoutBuffer = trailing
|
||||
} else {
|
||||
this.stderrBuffer = trailing
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
console.info(`[cli][${stream}] ${line}`)
|
||||
this.emit("log", { stream, message: line })
|
||||
|
||||
const port = this.extractPort(line)
|
||||
if (port && this.status.state === "starting") {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
console.info(`[cli] ready on ${url}`)
|
||||
this.updateStatus({ state: "ready", port, url })
|
||||
this.emit("ready", this.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractPort(line: string): number | null {
|
||||
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
|
||||
if (readyMatch) {
|
||||
return parseInt(readyMatch[1], 10)
|
||||
}
|
||||
|
||||
if (line.toLowerCase().includes("http server listening")) {
|
||||
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
|
||||
if (httpMatch) {
|
||||
return parseInt(httpMatch[1], 10)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (typeof parsed.port === "number") {
|
||||
return parsed.port
|
||||
}
|
||||
} catch {
|
||||
// not JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private updateStatus(patch: Partial<CliStatus>) {
|
||||
this.status = { ...this.status, ...patch }
|
||||
this.emit("status", this.status)
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--port", "0"]
|
||||
|
||||
if (options.dev) {
|
||||
const uiPort = process.env.VITE_PORT || "3000"
|
||||
args.push("--ui-dev-server", `http://localhost:${uiPort}`, "--log-level", "debug")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||
const parts = [JSON.stringify(process.execPath)]
|
||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||
}
|
||||
parts.push(JSON.stringify(cliEntry.entry))
|
||||
args.forEach((arg) => parts.push(JSON.stringify(arg)))
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||
if (cliEntry.runner === "tsx") {
|
||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||
}
|
||||
|
||||
return { command: process.execPath, args: [cliEntry.entry, ...args] }
|
||||
}
|
||||
|
||||
private resolveCliEntry(options: StartOptions): CliEntryResolution {
|
||||
if (options.dev) {
|
||||
const tsxPath = this.resolveTsx()
|
||||
if (!tsxPath) {
|
||||
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
|
||||
}
|
||||
const devEntry = this.resolveDevEntry()
|
||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
}
|
||||
|
||||
const distEntry = this.resolveProdEntry()
|
||||
return { entry: distEntry, runner: "node" }
|
||||
}
|
||||
|
||||
private resolveTsx(): string | null {
|
||||
const candidates: Array<string | (() => string)> = [
|
||||
() => nodeRequire.resolve("tsx/cli"),
|
||||
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
|
||||
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
|
||||
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const resolved = typeof candidate === "function" ? candidate() : candidate
|
||||
if (resolved && existsSync(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveDevEntry(): string {
|
||||
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
private resolveProdEntry(): string {
|
||||
try {
|
||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||
if (existsSync(entry)) {
|
||||
return entry
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
}
|
||||
|
||||
121
packages/electron-app/electron/main/storage.ts
Normal file
121
packages/electron-app/electron/main/storage.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { app, ipcMain } from "electron"
|
||||
import { join } from "path"
|
||||
import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
const CONFIG_DIR = join(app.getPath("home"), ".config", "codenomad")
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json")
|
||||
const INSTANCES_DIR = join(CONFIG_DIR, "instances")
|
||||
|
||||
// File watching for config changes
|
||||
let configWatchers = new Set<number>()
|
||||
let configLastModified = 0
|
||||
let configCache: string | null = null
|
||||
|
||||
async function ensureDirectories() {
|
||||
try {
|
||||
await mkdir(CONFIG_DIR, { recursive: true })
|
||||
await mkdir(INSTANCES_DIR, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to create directories:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function readConfigWithCache(): Promise<string> {
|
||||
try {
|
||||
const stats = await stat(CONFIG_FILE)
|
||||
const currentModified = stats.mtime.getTime()
|
||||
|
||||
// If file hasn't been modified since last read, return cache
|
||||
if (configCache && configLastModified >= currentModified) {
|
||||
return configCache
|
||||
}
|
||||
|
||||
const content = await readFile(CONFIG_FILE, "utf-8")
|
||||
configCache = content
|
||||
configLastModified = currentModified
|
||||
return content
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read
|
||||
configCache = null
|
||||
configLastModified = 0
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateConfigCache() {
|
||||
configCache = null
|
||||
configLastModified = 0
|
||||
}
|
||||
|
||||
export function setupStorageIPC() {
|
||||
ensureDirectories()
|
||||
|
||||
ipcMain.handle("storage:getConfigPath", async () => CONFIG_FILE)
|
||||
ipcMain.handle("storage:getInstancesDir", async () => INSTANCES_DIR)
|
||||
|
||||
ipcMain.handle("storage:readConfigFile", async () => {
|
||||
try {
|
||||
return await readConfigWithCache()
|
||||
} catch (error) {
|
||||
// Return empty config if file doesn't exist
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("storage:writeConfigFile", async (_, content: string) => {
|
||||
try {
|
||||
await writeFile(CONFIG_FILE, content, "utf-8")
|
||||
invalidateConfigCache()
|
||||
|
||||
// Notify other renderer processes about config change
|
||||
const windows = require("electron").BrowserWindow.getAllWindows()
|
||||
windows.forEach((win: any) => {
|
||||
if (win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send("storage:configChanged")
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to write config file:", error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("storage:readInstanceFile", async (_, filename: string) => {
|
||||
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
|
||||
try {
|
||||
return await readFile(instanceFile, "utf-8")
|
||||
} catch (error) {
|
||||
// Return empty instance data if file doesn't exist
|
||||
return JSON.stringify({ messageHistory: [] }, null, 2)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("storage:writeInstanceFile", async (_, filename: string, content: string) => {
|
||||
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
|
||||
try {
|
||||
await writeFile(instanceFile, content, "utf-8")
|
||||
} catch (error) {
|
||||
console.error(`Failed to write instance file for ${filename}:`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("storage:deleteInstanceFile", async (_, filename: string) => {
|
||||
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
|
||||
try {
|
||||
if (existsSync(instanceFile)) {
|
||||
await unlink(instanceFile)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete instance file for ${filename}:`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up on app quit
|
||||
app.on("before-quit", () => {
|
||||
configCache = null
|
||||
configLastModified = 0
|
||||
})
|
||||
139
packages/electron-app/electron/main/user-shell.ts
Normal file
139
packages/electron-app/electron/main/user-shell.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { spawn, spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
interface ShellCommand {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
|
||||
function getDefaultShellPath(): string {
|
||||
if (process.env.SHELL && process.env.SHELL.trim().length > 0) {
|
||||
return process.env.SHELL
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return "/bin/zsh"
|
||||
}
|
||||
|
||||
return "/bin/bash"
|
||||
}
|
||||
|
||||
function wrapCommandForShell(command: string, shellPath: string): string {
|
||||
const shellName = path.basename(shellPath)
|
||||
|
||||
if (shellName.includes("bash")) {
|
||||
return 'if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
if (shellName.includes("zsh")) {
|
||||
return 'if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
function buildShellArgs(shellPath: string): string[] {
|
||||
const shellName = path.basename(shellPath)
|
||||
if (shellName.includes("zsh")) {
|
||||
return ["-l", "-i", "-c"]
|
||||
}
|
||||
return ["-l", "-c"]
|
||||
}
|
||||
|
||||
function sanitizeShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const cleaned = { ...env }
|
||||
delete cleaned.npm_config_prefix
|
||||
delete cleaned.NPM_CONFIG_PREFIX
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export function supportsUserShell(): boolean {
|
||||
return !isWindows
|
||||
}
|
||||
|
||||
export function buildUserShellCommand(userCommand: string): ShellCommand {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const shellPath = getDefaultShellPath()
|
||||
const script = wrapCommandForShell(userCommand, shellPath)
|
||||
const args = buildShellArgs(shellPath)
|
||||
|
||||
return {
|
||||
command: shellPath,
|
||||
args: [...args, script],
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserShellEnv(): NodeJS.ProcessEnv {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
return sanitizeShellEnv(process.env)
|
||||
}
|
||||
|
||||
export function runUserShellCommand(userCommand: string, timeoutMs = 5000): Promise<string> {
|
||||
if (!supportsUserShell()) {
|
||||
return Promise.reject(new Error("User shell invocation is only supported on POSIX platforms"))
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error(`Shell command timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim())
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `Shell command exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function runUserShellCommandSync(userCommand: string): string {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
const result = spawnSync(command, args, { encoding: "utf-8", env })
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || "").toString().trim()
|
||||
throw new Error(stderr || "Shell command failed")
|
||||
}
|
||||
|
||||
return (result.stdout || "").toString().trim()
|
||||
}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/electron-app/electron/preload/index.cjs
Normal file
24
packages/electron-app/electron/preload/index.cjs
Normal file
@@ -0,0 +1,24 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||
},
|
||||
onCliError: (callback) => {
|
||||
ipcRenderer.on("cli:error", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:error")
|
||||
},
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
listUsers: () => ipcRenderer.invoke("users:list"),
|
||||
getActiveUser: () => ipcRenderer.invoke("users:active"),
|
||||
createUser: (payload) => ipcRenderer.invoke("users:create", payload),
|
||||
updateUser: (payload) => ipcRenderer.invoke("users:update", payload),
|
||||
deleteUser: (payload) => ipcRenderer.invoke("users:delete", payload),
|
||||
createGuest: () => ipcRenderer.invoke("users:createGuest"),
|
||||
loginUser: (payload) => ipcRenderer.invoke("users:login", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
BIN
packages/electron-app/electron/resources/icon.icns
Normal file
BIN
packages/electron-app/electron/resources/icon.icns
Normal file
Binary file not shown.
BIN
packages/electron-app/electron/resources/icon.ico
Normal file
BIN
packages/electron-app/electron/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
packages/electron-app/electron/resources/icon.png
Normal file
BIN
packages/electron-app/electron/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
7
packages/electron-app/electron/tsconfig.json
Normal file
7
packages/electron-app/electron/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx"]
|
||||
}
|
||||
138
packages/electron-app/package.json
Normal file
138
packages/electron-app/package.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.4.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
|
||||
},
|
||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:electron": "cross-env NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"preview": "electron-vite preview",
|
||||
"build:binaries": "node scripts/build.js",
|
||||
"build:mac": "node scripts/build.js mac",
|
||||
"build:mac-x64": "node scripts/build.js mac-x64",
|
||||
"build:mac-arm64": "node scripts/build.js mac-arm64",
|
||||
"build:win": "node scripts/build.js win",
|
||||
"build:win-arm64": "node scripts/build.js win-arm64",
|
||||
"build:linux": "node scripts/build.js linux",
|
||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||
"build:all": "node scripts/build.js all",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"package:win": "electron-builder --win",
|
||||
"package:linux": "electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neuralnomads/codenomad": "file:../server",
|
||||
"@codenomad/ui": "file:../ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"app-builder-bin": "^4.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "39.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
"png2icons": "^2.0.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.opencode.client",
|
||||
"productName": "CodeNomad",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "electron/resources"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources",
|
||||
"to": "",
|
||||
"filter": [
|
||||
"!icon.icns",
|
||||
"!icon.ico"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"category": "Development",
|
||||
"icon": "electron/resources/icon.png"
|
||||
}
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
131
packages/electron-app/scripts/build.js
Normal file
131
packages/electron-app/scripts/build.js
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const appDir = join(__dirname, "..")
|
||||
const workspaceRoot = join(appDir, "..", "..")
|
||||
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
const nodeModulesPath = join(appDir, "node_modules")
|
||||
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
||||
|
||||
const platforms = {
|
||||
mac: {
|
||||
args: ["--mac", "--x64", "--arm64"],
|
||||
description: "macOS (Intel & Apple Silicon)",
|
||||
},
|
||||
"mac-x64": {
|
||||
args: ["--mac", "--x64"],
|
||||
description: "macOS (Intel only)",
|
||||
},
|
||||
"mac-arm64": {
|
||||
args: ["--mac", "--arm64"],
|
||||
description: "macOS (Apple Silicon only)",
|
||||
},
|
||||
win: {
|
||||
args: ["--win", "--x64"],
|
||||
description: "Windows (x64)",
|
||||
},
|
||||
"win-arm64": {
|
||||
args: ["--win", "--arm64"],
|
||||
description: "Windows (ARM64)",
|
||||
},
|
||||
linux: {
|
||||
args: ["--linux", "--x64"],
|
||||
description: "Linux (x64)",
|
||||
},
|
||||
"linux-arm64": {
|
||||
args: ["--linux", "--arm64"],
|
||||
description: "Linux (ARM64)",
|
||||
},
|
||||
"linux-rpm": {
|
||||
args: ["--linux", "rpm", "--x64", "--arm64"],
|
||||
description: "Linux RPM packages (x64 & ARM64)",
|
||||
},
|
||||
all: {
|
||||
args: ["--mac", "--win", "--linux", "--x64", "--arm64"],
|
||||
description: "All platforms (macOS, Windows, Linux)",
|
||||
},
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const spawnOptions = {
|
||||
cwd: appDir,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
...options,
|
||||
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
|
||||
}
|
||||
|
||||
const child = spawn(command, args, spawnOptions)
|
||||
|
||||
child.on("error", reject)
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(undefined)
|
||||
} else {
|
||||
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function printAvailablePlatforms() {
|
||||
console.error(`\nAvailable platforms:`)
|
||||
for (const [name, cfg] of Object.entries(platforms)) {
|
||||
console.error(` - ${name.padEnd(12)} : ${cfg.description}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function build(platform) {
|
||||
const config = platforms[platform]
|
||||
|
||||
if (!config) {
|
||||
console.error(`❌ Unknown platform: ${platform}`)
|
||||
printAvailablePlatforms()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||
|
||||
try {
|
||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||
await run(npmCmd, ["run", "build"])
|
||||
|
||||
console.log("\n📦 Step 3/3: Packaging binaries...\n")
|
||||
const distPath = join(appDir, "dist")
|
||||
if (!existsSync(distPath)) {
|
||||
throw new Error("dist/ directory not found. Build failed.")
|
||||
}
|
||||
|
||||
await run(npxCmd, ["electron-builder", "--publish=never", ...config.args])
|
||||
|
||||
console.log("\n✅ Build complete!")
|
||||
console.log(`📁 Binaries available in: ${join(appDir, "release")}\n`)
|
||||
} catch (error) {
|
||||
console.error("\n❌ Build failed:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const platform = process.argv[2] || "mac"
|
||||
|
||||
console.log(`
|
||||
╔════════════════════════════════════════╗
|
||||
║ CodeNomad - Binary Builder ║
|
||||
╚════════════════════════════════════════╝
|
||||
`)
|
||||
|
||||
await build(platform)
|
||||
30
packages/electron-app/scripts/dev.sh
Normal file
30
packages/electron-app/scripts/dev.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "Node.js is required to run the development environment." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the Electron binary via Node to avoid Bun resolution hiccups
|
||||
ELECTRON_EXEC_PATH="$(node -p "require('electron')")"
|
||||
|
||||
if [[ -z "${ELECTRON_EXEC_PATH}" ]]; then
|
||||
echo "Failed to resolve the Electron binary path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export NODE_ENV="${NODE_ENV:-development}"
|
||||
export ELECTRON_EXEC_PATH
|
||||
|
||||
# ELECTRON_VITE_BIN="$ROOT_DIR/node_modules/.bin/electron-vite"
|
||||
|
||||
if [[ ! -x "${ELECTRON_VITE_BIN}" ]]; then
|
||||
echo "electron-vite binary not found. Have you installed dependencies?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "${ELECTRON_VITE_BIN}" dev "$@"
|
||||
155
packages/electron-app/scripts/generate-icons.js
Normal file
155
packages/electron-app/scripts/generate-icons.js
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "fs"
|
||||
import { resolve, join, basename } from "path"
|
||||
import { PNG } from "pngjs"
|
||||
import png2icons from "png2icons"
|
||||
|
||||
function printUsage() {
|
||||
console.log(`\nUsage: node scripts/generate-icons.js <input.png> [outputDir] [--name icon] [--radius 0.22]\n\nOptions:\n --name Base filename for generated assets (default: icon)\n --radius Corner radius ratio between 0 and 0.5 (default: 0.22)\n --help Show this message\n`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv]
|
||||
const options = {
|
||||
name: "icon",
|
||||
radius: 0.22,
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const token = args[i]
|
||||
if (token === "--help" || token === "-h") {
|
||||
options.help = true
|
||||
continue
|
||||
}
|
||||
if (token === "--name" && i + 1 < args.length) {
|
||||
options.name = args[i + 1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (token === "--radius" && i + 1 < args.length) {
|
||||
options.radius = Number(args[i + 1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (!options.input) {
|
||||
options.input = token
|
||||
continue
|
||||
}
|
||||
if (!options.output) {
|
||||
options.output = token
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function applyRoundedCorners(png, ratio) {
|
||||
const { width, height, data } = png
|
||||
const clamped = Math.max(0, Math.min(ratio, 0.5))
|
||||
if (clamped === 0) return png
|
||||
|
||||
const radius = Math.max(1, Math.min(width, height) * clamped)
|
||||
const radiusSq = radius * radius
|
||||
const rightThreshold = width - radius
|
||||
const bottomThreshold = height - radius
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = (width * y + x) * 4
|
||||
if (data[idx + 3] === 0) continue
|
||||
|
||||
const px = x + 0.5
|
||||
const py = y + 0.5
|
||||
|
||||
const inLeft = px < radius
|
||||
const inRight = px > rightThreshold
|
||||
const inTop = py < radius
|
||||
const inBottom = py > bottomThreshold
|
||||
|
||||
let outside = false
|
||||
|
||||
if (inLeft && inTop) {
|
||||
outside = (px - radius) ** 2 + (py - radius) ** 2 > radiusSq
|
||||
} else if (inRight && inTop) {
|
||||
outside = (px - rightThreshold) ** 2 + (py - radius) ** 2 > radiusSq
|
||||
} else if (inLeft && inBottom) {
|
||||
outside = (px - radius) ** 2 + (py - bottomThreshold) ** 2 > radiusSq
|
||||
} else if (inRight && inBottom) {
|
||||
outside = (px - rightThreshold) ** 2 + (py - bottomThreshold) ** 2 > radiusSq
|
||||
}
|
||||
|
||||
if (outside) {
|
||||
data[idx + 3] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return png
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help || !args.input) {
|
||||
printUsage()
|
||||
process.exit(args.help ? 0 : 1)
|
||||
}
|
||||
|
||||
const inputPath = resolve(args.input)
|
||||
const outputDir = resolve(args.output || "electron/resources")
|
||||
const baseName = args.name || basename(inputPath, ".png")
|
||||
const radiusRatio = Number.isFinite(args.radius) ? args.radius : 0.22
|
||||
|
||||
let buffer
|
||||
try {
|
||||
buffer = readFileSync(inputPath)
|
||||
} catch (error) {
|
||||
console.error(`Failed to read ${inputPath}:`, error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let png
|
||||
try {
|
||||
png = PNG.sync.read(buffer)
|
||||
} catch (error) {
|
||||
console.error("Input must be a valid PNG:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
applyRoundedCorners(png, radiusRatio)
|
||||
|
||||
const roundedBuffer = PNG.sync.write(png)
|
||||
|
||||
try {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to create output directory:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const pngPath = join(outputDir, `${baseName}.png`)
|
||||
writeFileSync(pngPath, roundedBuffer)
|
||||
|
||||
const icns = png2icons.createICNS(roundedBuffer, png2icons.BICUBIC, false)
|
||||
if (!icns) {
|
||||
console.error("Failed to create ICNS file. Make sure the source PNG is at least 256x256.")
|
||||
process.exit(1)
|
||||
}
|
||||
writeFileSync(join(outputDir, `${baseName}.icns`), icns)
|
||||
|
||||
const ico = png2icons.createICO(roundedBuffer, png2icons.BICUBIC, false)
|
||||
if (!ico) {
|
||||
console.error("Failed to create ICO file. Make sure the source PNG is at least 256x256.")
|
||||
process.exit(1)
|
||||
}
|
||||
writeFileSync(join(outputDir, `${baseName}.ico`), ico)
|
||||
|
||||
console.log(`\nGenerated assets in ${outputDir}:`)
|
||||
console.log(`- ${baseName}.png`)
|
||||
console.log(`- ${baseName}.icns`)
|
||||
console.log(`- ${baseName}.ico`)
|
||||
}
|
||||
|
||||
main()
|
||||
18
packages/electron-app/tsconfig.json
Normal file
18
packages/electron-app/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020"],
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
3
packages/opencode-config/opencode.jsonc
Normal file
3
packages/opencode-config/opencode.jsonc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}
|
||||
8
packages/opencode-config/plugin/hello.js
Normal file
8
packages/opencode-config/plugin/hello.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// NomadArch Plugin Template
|
||||
// This file is a placeholder. OpenCode plugins are optional.
|
||||
// To create a plugin, see: https://opencode.ai/docs/plugins
|
||||
|
||||
export async function init() {
|
||||
// No-op placeholder - customize as needed
|
||||
return {}
|
||||
}
|
||||
1
packages/server/.gitignore
vendored
Normal file
1
packages/server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
public/
|
||||
5
packages/server/.npmignore
Normal file
5
packages/server/.npmignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
scripts/
|
||||
src/
|
||||
tsconfig.json
|
||||
*.tsbuildinfo
|
||||
58
packages/server/README.md
Normal file
58
packages/server/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# CodeNomad Server
|
||||
|
||||
**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace.
|
||||
|
||||
## Features & Capabilities
|
||||
|
||||
### 🌍 Deployment Freedom
|
||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||
|
||||
### ⚡️ Workspace Power
|
||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||
|
||||
## Prerequisites
|
||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||
- Node.js 18+ and npm (for running or building from source).
|
||||
- A workspace folder on disk you want to serve.
|
||||
- Optional: a Chromium-based browser if you want `--launch` to open the UI automatically.
|
||||
|
||||
## Usage
|
||||
|
||||
### Run via npx (Recommended)
|
||||
You can run CodeNomad directly without installing it:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
### Install Globally
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
```sh
|
||||
npm install -g @neuralnomads/codenomad
|
||||
codenomad --launch
|
||||
```
|
||||
|
||||
### Common Flags
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
| Flag | Env Variable | Description |
|
||||
|------|--------------|-------------|
|
||||
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
|
||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||
|
||||
### Data Storage
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
|
||||
1333
packages/server/package-lock.json
generated
Normal file
1333
packages/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
packages/server/package.json
Normal file
44
packages/server/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.4.0",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"pino": "^9.4.0",
|
||||
"ulid": "^3.0.2",
|
||||
"undici": "^6.19.8",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
21
packages/server/scripts/copy-opencode-config.mjs
Normal file
21
packages/server/scripts/copy-opencode-config.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
const sourceDir = path.resolve(cliRoot, "../opencode-config")
|
||||
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||
cpSync(sourceDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
||||
21
packages/server/scripts/copy-ui-dist.mjs
Normal file
21
packages/server/scripts/copy-ui-dist.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
const uiDistDir = path.resolve(cliRoot, "../ui/src/renderer/dist")
|
||||
const targetDir = path.resolve(cliRoot, "public")
|
||||
|
||||
if (!existsSync(uiDistDir)) {
|
||||
console.error(`[copy-ui-dist] Expected UI build artifacts at ${uiDistDir}. Run the UI build before bundling the CLI.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
cpSync(uiDistDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-ui-dist] Copied UI bundle from ${uiDistDir} -> ${targetDir}`)
|
||||
318
packages/server/src/api-types.ts
Normal file
318
packages/server/src/api-types.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import type {
|
||||
AgentModelSelection,
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ModelPreference,
|
||||
OpenCodeBinary,
|
||||
Preferences,
|
||||
RecentFolder,
|
||||
} from "./config/schema"
|
||||
|
||||
export type TaskStatus = "completed" | "interrupted" | "in-progress" | "pending"
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
timestamp: number
|
||||
messageIds?: string[] // IDs of messages associated with this task
|
||||
}
|
||||
|
||||
export interface SessionTasks {
|
||||
[sessionId: string]: Task[]
|
||||
}
|
||||
|
||||
export interface SkillSelection {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface SkillDescriptor {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface SkillDetail extends SkillDescriptor {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SkillCatalogResponse {
|
||||
skills: SkillDescriptor[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical HTTP/SSE contract for the CLI server.
|
||||
* These types are consumed by both the CLI implementation and any UI clients.
|
||||
*/
|
||||
|
||||
export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
|
||||
|
||||
export interface WorkspaceDescriptor {
|
||||
id: string
|
||||
/** Absolute path on the server host. */
|
||||
path: string
|
||||
name?: string
|
||||
status: WorkspaceStatus
|
||||
/** PID/port are populated when the workspace is running. */
|
||||
pid?: number
|
||||
port?: number
|
||||
/** Canonical proxy path the CLI exposes for this instance. */
|
||||
proxyPath: string
|
||||
/** Identifier of the binary resolved from config. */
|
||||
binaryId: string
|
||||
binaryLabel: string
|
||||
binaryVersion?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** Present when `status` is "error". */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface WorkspaceCreateRequest {
|
||||
path: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type WorkspaceCreateResponse = WorkspaceDescriptor
|
||||
export type WorkspaceListResponse = WorkspaceDescriptor[]
|
||||
export type WorkspaceDetailResponse = WorkspaceDescriptor
|
||||
|
||||
export interface WorkspaceExportRequest {
|
||||
destination: string
|
||||
includeConfig?: boolean
|
||||
}
|
||||
|
||||
export interface WorkspaceExportResponse {
|
||||
destination: string
|
||||
}
|
||||
|
||||
export interface WorkspaceImportRequest {
|
||||
source: string
|
||||
destination: string
|
||||
includeConfig?: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceImportResponse = WorkspaceDescriptor
|
||||
|
||||
export interface WorkspaceMcpConfig {
|
||||
mcpServers?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface WorkspaceMcpConfigResponse {
|
||||
path: string
|
||||
exists: boolean
|
||||
config: WorkspaceMcpConfig
|
||||
}
|
||||
|
||||
export interface WorkspaceMcpConfigRequest {
|
||||
config: WorkspaceMcpConfig
|
||||
}
|
||||
|
||||
export interface WorkspaceDeleteResponse {
|
||||
id: string
|
||||
status: WorkspaceStatus
|
||||
}
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error"
|
||||
|
||||
export interface WorkspaceLogEntry {
|
||||
workspaceId: string
|
||||
timestamp: string
|
||||
level: LogLevel
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface FileSystemEntry {
|
||||
name: string
|
||||
/** Path relative to the CLI server root ("." represents the root itself). */
|
||||
path: string
|
||||
/** Absolute path when available (unrestricted listings). */
|
||||
absolutePath?: string
|
||||
type: "file" | "directory"
|
||||
size?: number
|
||||
/** ISO timestamp of last modification when available. */
|
||||
modifiedAt?: string
|
||||
}
|
||||
|
||||
export type FileSystemScope = "restricted" | "unrestricted"
|
||||
export type FileSystemPathKind = "relative" | "absolute" | "drives"
|
||||
|
||||
export interface FileSystemListingMetadata {
|
||||
scope: FileSystemScope
|
||||
/** Canonical identifier of the current view ("." for restricted roots, absolute paths otherwise). */
|
||||
currentPath: string
|
||||
/** Optional parent path if navigation upward is allowed. */
|
||||
parentPath?: string
|
||||
/** Absolute path representing the root or origin point for this listing. */
|
||||
rootPath: string
|
||||
/** Absolute home directory of the CLI host (useful defaults for unrestricted mode). */
|
||||
homePath: string
|
||||
/** Human-friendly label for the current path. */
|
||||
displayPath: string
|
||||
/** Indicates whether entry paths are relative, absolute, or represent drive roots. */
|
||||
pathKind: FileSystemPathKind
|
||||
}
|
||||
|
||||
export interface FileSystemListResponse {
|
||||
entries: FileSystemEntry[]
|
||||
metadata: FileSystemListingMetadata
|
||||
}
|
||||
|
||||
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||
|
||||
export interface WorkspaceFileResponse {
|
||||
workspaceId: string
|
||||
relativePath: string
|
||||
/** UTF-8 file contents; binary files should be base64 encoded by the caller. */
|
||||
contents: string
|
||||
}
|
||||
|
||||
export type WorkspaceFileSearchResponse = FileSystemEntry[]
|
||||
|
||||
export interface WorkspaceGitStatusEntry {
|
||||
path: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface WorkspaceGitStatus {
|
||||
isRepo: boolean
|
||||
branch: string | null
|
||||
ahead: number
|
||||
behind: number
|
||||
changes: WorkspaceGitStatusEntry[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface InstanceData {
|
||||
messageHistory: string[]
|
||||
agentModelSelections: AgentModelSelection
|
||||
sessionTasks?: SessionTasks // Multi-task chat support: tasks per session
|
||||
sessionSkills?: Record<string, SkillSelection[]> // Selected skills per session
|
||||
customAgents?: Array<{
|
||||
name: string
|
||||
description?: string
|
||||
prompt: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||
|
||||
export interface InstanceStreamEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BinaryRecord {
|
||||
id: string
|
||||
path: string
|
||||
label: string
|
||||
version?: string
|
||||
|
||||
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
||||
isDefault: boolean
|
||||
lastValidatedAt?: string
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export type AppConfig = ConfigFile
|
||||
export type AppConfigResponse = AppConfig
|
||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
||||
|
||||
export interface BinaryListResponse {
|
||||
binaries: BinaryRecord[]
|
||||
}
|
||||
|
||||
export interface BinaryCreateRequest {
|
||||
path: string
|
||||
label?: string
|
||||
makeDefault?: boolean
|
||||
}
|
||||
|
||||
export interface BinaryUpdateRequest {
|
||||
label?: string
|
||||
makeDefault?: boolean
|
||||
}
|
||||
|
||||
export interface BinaryValidationResult {
|
||||
valid: boolean
|
||||
version?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
| "workspace.error"
|
||||
| "workspace.stopped"
|
||||
| "workspace.log"
|
||||
| "config.appChanged"
|
||||
| "config.binariesChanged"
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
| "app.releaseAvailable"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.started"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.stopped"; workspaceId: string }
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "config.appChanged"; config: AppConfig }
|
||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
|
||||
|
||||
export interface NetworkAddress {
|
||||
ip: string
|
||||
family: "ipv4" | "ipv6"
|
||||
scope: "external" | "internal" | "loopback"
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface LatestReleaseInfo {
|
||||
version: string
|
||||
tag: string
|
||||
url: string
|
||||
channel: "stable" | "dev"
|
||||
publishedAt?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
httpBaseUrl: string
|
||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||
eventsUrl: string
|
||||
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||
host: string
|
||||
/** Listening mode derived from host binding. */
|
||||
listeningMode: "local" | "all"
|
||||
/** Actual port in use after binding. */
|
||||
port: number
|
||||
/** Display label for the host (e.g., hostname or friendly name). */
|
||||
hostLabel: string
|
||||
/** Absolute path of the filesystem root exposed to clients. */
|
||||
workspaceRoot: string
|
||||
/** Reachable addresses for this server, external first. */
|
||||
addresses: NetworkAddress[]
|
||||
/** Optional metadata about the most recent public release. */
|
||||
latestRelease?: LatestReleaseInfo
|
||||
}
|
||||
|
||||
export interface PortAvailabilityResponse {
|
||||
port: number
|
||||
}
|
||||
|
||||
export type {
|
||||
Preferences,
|
||||
ModelPreference,
|
||||
AgentModelSelections,
|
||||
RecentFolder,
|
||||
OpenCodeBinary,
|
||||
}
|
||||
29
packages/server/src/bin.ts
Normal file
29
packages/server/src/bin.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliEntry = path.join(__dirname, "index.js")
|
||||
const loaderFileUrl = pathToFileURL(path.join(__dirname, "loader.js")).href
|
||||
const registerScript = `import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("${encodeURI(loaderFileUrl)}", pathToFileURL("./"));`
|
||||
const loaderArg = `data:text/javascript,${registerScript}`
|
||||
|
||||
const child = spawn(process.execPath, ["--import", loaderArg, cliEntry, ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("Failed to launch CLI runtime", error)
|
||||
process.exit(1)
|
||||
})
|
||||
156
packages/server/src/config/binaries.ts
Normal file
156
packages/server/src/config/binaries.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
BinaryCreateRequest,
|
||||
BinaryRecord,
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
} from "../api-types"
|
||||
import { ConfigStore } from "./store"
|
||||
import { EventBus } from "../events/bus"
|
||||
import type { ConfigFile } from "./schema"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export class BinaryRegistry {
|
||||
constructor(
|
||||
private readonly configStore: ConfigStore,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
list(): BinaryRecord[] {
|
||||
return this.mapRecords()
|
||||
}
|
||||
|
||||
resolveDefault(): BinaryRecord {
|
||||
const binaries = this.mapRecords()
|
||||
if (binaries.length === 0) {
|
||||
this.logger.warn("No configured binaries found, falling back to opencode")
|
||||
return this.buildFallbackRecord("opencode")
|
||||
}
|
||||
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
||||
}
|
||||
|
||||
create(request: BinaryCreateRequest): BinaryRecord {
|
||||
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
|
||||
const entry = {
|
||||
path: request.path,
|
||||
version: undefined,
|
||||
lastUsed: Date.now(),
|
||||
label: request.label,
|
||||
}
|
||||
|
||||
const config = this.configStore.get()
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
||||
nextConfig.opencodeBinaries = [entry, ...deduped]
|
||||
|
||||
if (request.makeDefault) {
|
||||
nextConfig.preferences.lastUsedBinary = request.path
|
||||
}
|
||||
|
||||
this.configStore.replace(nextConfig)
|
||||
const record = this.getById(request.path)
|
||||
this.emitChange()
|
||||
return record
|
||||
}
|
||||
|
||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
||||
this.logger.debug({ id }, "Updating OpenCode binary")
|
||||
const config = this.configStore.get()
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
|
||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
||||
)
|
||||
|
||||
if (updates.makeDefault) {
|
||||
nextConfig.preferences.lastUsedBinary = id
|
||||
}
|
||||
|
||||
this.configStore.replace(nextConfig)
|
||||
const record = this.getById(id)
|
||||
this.emitChange()
|
||||
return record
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
this.logger.debug({ id }, "Removing OpenCode binary")
|
||||
const config = this.configStore.get()
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||
nextConfig.opencodeBinaries = remaining
|
||||
|
||||
if (nextConfig.preferences.lastUsedBinary === id) {
|
||||
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
|
||||
}
|
||||
|
||||
this.configStore.replace(nextConfig)
|
||||
this.emitChange()
|
||||
}
|
||||
|
||||
validatePath(path: string): BinaryValidationResult {
|
||||
this.logger.debug({ path }, "Validating OpenCode binary path")
|
||||
return this.validateRecord({
|
||||
id: path,
|
||||
path,
|
||||
label: this.prettyLabel(path),
|
||||
isDefault: false,
|
||||
})
|
||||
}
|
||||
|
||||
private cloneConfig(config: ConfigFile): ConfigFile {
|
||||
return JSON.parse(JSON.stringify(config)) as ConfigFile
|
||||
}
|
||||
|
||||
private mapRecords(): BinaryRecord[] {
|
||||
|
||||
const config = this.configStore.get()
|
||||
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
||||
id: binary.path,
|
||||
path: binary.path,
|
||||
label: binary.label ?? this.prettyLabel(binary.path),
|
||||
version: binary.version,
|
||||
isDefault: false,
|
||||
}))
|
||||
|
||||
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
|
||||
|
||||
const annotated = configuredBinaries.map((binary) => ({
|
||||
...binary,
|
||||
isDefault: binary.path === defaultPath,
|
||||
}))
|
||||
|
||||
if (!annotated.some((binary) => binary.path === defaultPath)) {
|
||||
annotated.unshift(this.buildFallbackRecord(defaultPath))
|
||||
}
|
||||
|
||||
return annotated
|
||||
}
|
||||
|
||||
private getById(id: string): BinaryRecord {
|
||||
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
|
||||
}
|
||||
|
||||
private emitChange() {
|
||||
this.logger.debug("Emitting binaries changed event")
|
||||
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
||||
}
|
||||
|
||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||
// TODO: call actual binary -v check.
|
||||
return { valid: true, version: record.version }
|
||||
}
|
||||
|
||||
private buildFallbackRecord(path: string): BinaryRecord {
|
||||
return {
|
||||
id: path,
|
||||
path,
|
||||
label: this.prettyLabel(path),
|
||||
isDefault: true,
|
||||
}
|
||||
}
|
||||
|
||||
private prettyLabel(path: string) {
|
||||
const parts = path.split(/[\\/]/)
|
||||
const last = parts[parts.length - 1] || path
|
||||
return last || path
|
||||
}
|
||||
}
|
||||
64
packages/server/src/config/schema.ts
Normal file
64
packages/server/src/config/schema.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const ModelPreferenceSchema = z.object({
|
||||
providerId: z.string(),
|
||||
modelId: z.string(),
|
||||
})
|
||||
|
||||
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||
|
||||
const PreferencesSchema = z.object({
|
||||
showThinkingBlocks: z.boolean().default(false),
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showTimelineTools: z.boolean().default(true),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
})
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
path: z.string(),
|
||||
lastAccessed: z.number().nonnegative(),
|
||||
})
|
||||
|
||||
const OpenCodeBinarySchema = z.object({
|
||||
path: z.string(),
|
||||
version: z.string().optional(),
|
||||
lastUsed: z.number().nonnegative(),
|
||||
label: z.string().optional(),
|
||||
})
|
||||
|
||||
const ConfigFileSchema = z.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
|
||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||
|
||||
export {
|
||||
ModelPreferenceSchema,
|
||||
AgentModelSelectionSchema,
|
||||
AgentModelSelectionsSchema,
|
||||
PreferencesSchema,
|
||||
RecentFolderSchema,
|
||||
OpenCodeBinarySchema,
|
||||
ConfigFileSchema,
|
||||
DEFAULT_CONFIG,
|
||||
}
|
||||
|
||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||
export type AgentModelSelection = z.infer<typeof AgentModelSelectionSchema>
|
||||
export type AgentModelSelections = z.infer<typeof AgentModelSelectionsSchema>
|
||||
export type Preferences = z.infer<typeof PreferencesSchema>
|
||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||
78
packages/server/src/config/store.ts
Normal file
78
packages/server/src/config/store.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
|
||||
|
||||
export class ConfigStore {
|
||||
private cache: ConfigFile = DEFAULT_CONFIG
|
||||
private loaded = false
|
||||
|
||||
constructor(
|
||||
private readonly configPath: string,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
load(): ConfigFile {
|
||||
if (this.loaded) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = this.resolvePath(this.configPath)
|
||||
if (fs.existsSync(resolved)) {
|
||||
const content = fs.readFileSync(resolved, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
this.cache = ConfigFileSchema.parse(parsed)
|
||||
this.logger.debug({ resolved }, "Loaded existing config file")
|
||||
} else {
|
||||
this.cache = DEFAULT_CONFIG
|
||||
this.logger.debug({ resolved }, "No config file found, using defaults")
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to load config, using defaults")
|
||||
this.cache = DEFAULT_CONFIG
|
||||
}
|
||||
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
}
|
||||
|
||||
get(): ConfigFile {
|
||||
return this.load()
|
||||
}
|
||||
|
||||
replace(config: ConfigFile) {
|
||||
const validated = ConfigFileSchema.parse(config)
|
||||
this.commit(validated)
|
||||
}
|
||||
|
||||
private commit(next: ConfigFile) {
|
||||
this.cache = next
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
const published = Boolean(this.eventBus)
|
||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
|
||||
this.logger.trace({ config: this.cache }, "Config payload")
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
const resolved = this.resolvePath(this.configPath)
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
||||
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
||||
this.logger.debug({ resolved }, "Persisted config file")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist config")
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
}
|
||||
189
packages/server/src/context-engine/client.ts
Normal file
189
packages/server/src/context-engine/client.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Context Engine HTTP Client
|
||||
* Communicates with the Context-Engine RAG service for code retrieval and memory management.
|
||||
*/
|
||||
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export interface ContextEngineConfig {
|
||||
/** Base URL of the Context-Engine API (default: http://localhost:8000) */
|
||||
baseUrl: string
|
||||
/** Request timeout in milliseconds (default: 30000) */
|
||||
timeout: number
|
||||
}
|
||||
|
||||
export interface IndexRequest {
|
||||
path: string
|
||||
recursive?: boolean
|
||||
}
|
||||
|
||||
export interface IndexResponse {
|
||||
status: "started" | "completed" | "error"
|
||||
indexed_files?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface QueryRequest {
|
||||
query: string
|
||||
context_window?: number
|
||||
top_k?: number
|
||||
}
|
||||
|
||||
export interface QueryResponse {
|
||||
results: Array<{
|
||||
content: string
|
||||
file_path: string
|
||||
score: number
|
||||
metadata?: Record<string, unknown>
|
||||
}>
|
||||
total_results: number
|
||||
}
|
||||
|
||||
export interface MemoryRequest {
|
||||
text: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface MemoryResponse {
|
||||
id: string
|
||||
status: "added" | "error"
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: "healthy" | "unhealthy"
|
||||
version?: string
|
||||
indexed_files?: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ContextEngineConfig = {
|
||||
baseUrl: "http://localhost:8000",
|
||||
timeout: 30000,
|
||||
}
|
||||
|
||||
export class ContextEngineClient {
|
||||
private config: ContextEngineConfig
|
||||
private logger: Logger
|
||||
|
||||
constructor(config: Partial<ContextEngineConfig> = {}, logger: Logger) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config }
|
||||
this.logger = logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Context-Engine is healthy and responding
|
||||
*/
|
||||
async health(): Promise<HealthResponse> {
|
||||
try {
|
||||
const response = await this.request<HealthResponse>("/health", {
|
||||
method: "GET",
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
this.logger.debug({ error }, "Context-Engine health check failed")
|
||||
return { status: "unhealthy" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger indexing for a project path
|
||||
*/
|
||||
async index(path: string, recursive = true): Promise<IndexResponse> {
|
||||
this.logger.info({ path, recursive }, "Triggering Context-Engine indexing")
|
||||
|
||||
try {
|
||||
const response = await this.request<IndexResponse>("/index", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path, recursive } as IndexRequest),
|
||||
})
|
||||
this.logger.info({ path, response }, "Context-Engine indexing response")
|
||||
return response
|
||||
} catch (error) {
|
||||
this.logger.error({ path, error }, "Context-Engine indexing failed")
|
||||
return {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the Context-Engine for relevant code snippets
|
||||
*/
|
||||
async query(prompt: string, contextWindow = 4096, topK = 5): Promise<QueryResponse> {
|
||||
this.logger.debug({ prompt: prompt.slice(0, 100), contextWindow, topK }, "Querying Context-Engine")
|
||||
|
||||
try {
|
||||
const response = await this.request<QueryResponse>("/query", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
query: prompt,
|
||||
context_window: contextWindow,
|
||||
top_k: topK,
|
||||
} as QueryRequest),
|
||||
})
|
||||
this.logger.debug({ resultCount: response.results.length }, "Context-Engine query completed")
|
||||
return response
|
||||
} catch (error) {
|
||||
this.logger.warn({ error }, "Context-Engine query failed")
|
||||
return { results: [], total_results: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a memory/rule to the Context-Engine for session-specific context
|
||||
*/
|
||||
async addMemory(text: string, metadata?: Record<string, unknown>): Promise<MemoryResponse> {
|
||||
this.logger.debug({ textLength: text.length }, "Adding memory to Context-Engine")
|
||||
|
||||
try {
|
||||
const response = await this.request<MemoryResponse>("/memory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text, metadata } as MemoryRequest),
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
this.logger.warn({ error }, "Context-Engine addMemory failed")
|
||||
return { id: "", status: "error" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current indexing status
|
||||
*/
|
||||
async getStatus(): Promise<{ indexing: boolean; indexed_files: number; last_indexed?: string }> {
|
||||
try {
|
||||
const response = await this.request<{ indexing: boolean; indexed_files: number; last_indexed?: string }>("/status", {
|
||||
method: "GET",
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
return { indexing: false, indexed_files: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit): Promise<T> {
|
||||
const url = `${this.config.baseUrl}${endpoint}`
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "")
|
||||
throw new Error(`Context-Engine request failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
return await response.json() as T
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
packages/server/src/context-engine/index.ts
Normal file
13
packages/server/src/context-engine/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Context Engine module exports
|
||||
*/
|
||||
|
||||
export { ContextEngineClient, type ContextEngineConfig, type QueryResponse, type IndexResponse } from "./client"
|
||||
export {
|
||||
ContextEngineService,
|
||||
type ContextEngineServiceConfig,
|
||||
type ContextEngineStatus,
|
||||
getContextEngineService,
|
||||
initializeContextEngineService,
|
||||
shutdownContextEngineService,
|
||||
} from "./service"
|
||||
350
packages/server/src/context-engine/service.ts
Normal file
350
packages/server/src/context-engine/service.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Context Engine Service
|
||||
* Manages the lifecycle of the Context-Engine process (Python sidecar)
|
||||
* and provides access to the Context-Engine client.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { EventEmitter } from "events"
|
||||
import { Logger } from "../logger"
|
||||
import { ContextEngineClient, ContextEngineConfig, HealthResponse } from "./client"
|
||||
|
||||
export type ContextEngineStatus = "stopped" | "starting" | "ready" | "indexing" | "error"
|
||||
|
||||
export interface ContextEngineServiceConfig {
|
||||
/** Path to the context-engine executable or Python script */
|
||||
binaryPath?: string
|
||||
/** Arguments to pass to the context-engine process */
|
||||
args?: string[]
|
||||
/** Port for the Context-Engine API (default: 8000) */
|
||||
port: number
|
||||
/** Host for the Context-Engine API (default: localhost) */
|
||||
host: string
|
||||
/** Whether to auto-start the engine when first needed (lazy start) */
|
||||
lazyStart: boolean
|
||||
/** Health check interval in milliseconds */
|
||||
healthCheckInterval: number
|
||||
/** Max retries for health check before marking as error */
|
||||
maxHealthCheckRetries: number
|
||||
}
|
||||
|
||||
const DEFAULT_SERVICE_CONFIG: ContextEngineServiceConfig = {
|
||||
binaryPath: "context-engine",
|
||||
args: [],
|
||||
port: 8000,
|
||||
host: "localhost",
|
||||
lazyStart: true,
|
||||
healthCheckInterval: 5000,
|
||||
maxHealthCheckRetries: 3,
|
||||
}
|
||||
|
||||
export class ContextEngineService extends EventEmitter {
|
||||
private config: ContextEngineServiceConfig
|
||||
private logger: Logger
|
||||
private process: ChildProcess | null = null
|
||||
private client: ContextEngineClient
|
||||
private status: ContextEngineStatus = "stopped"
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null
|
||||
private healthCheckFailures = 0
|
||||
private indexingPaths = new Set<string>()
|
||||
|
||||
constructor(config: Partial<ContextEngineServiceConfig> = {}, logger: Logger) {
|
||||
super()
|
||||
this.config = { ...DEFAULT_SERVICE_CONFIG, ...config }
|
||||
this.logger = logger
|
||||
|
||||
const clientConfig: Partial<ContextEngineConfig> = {
|
||||
baseUrl: `http://${this.config.host}:${this.config.port}`,
|
||||
timeout: 30000,
|
||||
}
|
||||
this.client = new ContextEngineClient(clientConfig, logger)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status of the Context-Engine
|
||||
*/
|
||||
getStatus(): ContextEngineStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Context-Engine is ready to accept requests
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return this.status === "ready" || this.status === "indexing"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Context-Engine client for making API calls
|
||||
*/
|
||||
getClient(): ContextEngineClient {
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Context-Engine process
|
||||
*/
|
||||
async start(): Promise<boolean> {
|
||||
if (this.status === "ready" || this.status === "starting") {
|
||||
this.logger.debug("Context-Engine already started or starting")
|
||||
return true
|
||||
}
|
||||
|
||||
this.setStatus("starting")
|
||||
this.logger.info({ config: this.config }, "Starting Context-Engine service")
|
||||
|
||||
// First, check if an external Context-Engine is already running
|
||||
const externalHealth = await this.client.health()
|
||||
if (externalHealth.status === "healthy") {
|
||||
this.logger.info("External Context-Engine detected and healthy")
|
||||
this.setStatus("ready")
|
||||
this.startHealthCheck()
|
||||
return true
|
||||
}
|
||||
|
||||
// Try to spawn the process
|
||||
if (!this.config.binaryPath) {
|
||||
this.logger.warn("No binary path configured for Context-Engine")
|
||||
this.setStatus("error")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const args = [
|
||||
...(this.config.args || []),
|
||||
"--port", String(this.config.port),
|
||||
"--host", this.config.host,
|
||||
]
|
||||
|
||||
this.logger.info({ binary: this.config.binaryPath, args }, "Spawning Context-Engine process")
|
||||
|
||||
this.process = spawn(this.config.binaryPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
detached: false,
|
||||
})
|
||||
|
||||
this.process.stdout?.on("data", (data) => {
|
||||
this.logger.debug({ output: data.toString().trim() }, "Context-Engine stdout")
|
||||
})
|
||||
|
||||
this.process.stderr?.on("data", (data) => {
|
||||
this.logger.debug({ output: data.toString().trim() }, "Context-Engine stderr")
|
||||
})
|
||||
|
||||
this.process.on("error", (error) => {
|
||||
this.logger.error({ error }, "Context-Engine process error")
|
||||
this.setStatus("error")
|
||||
})
|
||||
|
||||
this.process.on("exit", (code, signal) => {
|
||||
this.logger.info({ code, signal }, "Context-Engine process exited")
|
||||
this.process = null
|
||||
if (this.status !== "stopped") {
|
||||
this.setStatus("error")
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for the process to become ready
|
||||
const ready = await this.waitForReady(30000)
|
||||
if (ready) {
|
||||
this.setStatus("ready")
|
||||
this.startHealthCheck()
|
||||
return true
|
||||
} else {
|
||||
this.logger.error("Context-Engine failed to become ready")
|
||||
this.setStatus("error")
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, "Failed to spawn Context-Engine process")
|
||||
this.setStatus("error")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Context-Engine process
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.stopHealthCheck()
|
||||
this.setStatus("stopped")
|
||||
|
||||
if (this.process) {
|
||||
this.logger.info("Stopping Context-Engine process")
|
||||
this.process.kill("SIGTERM")
|
||||
|
||||
// Wait for graceful shutdown
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.process) {
|
||||
this.logger.warn("Context-Engine did not exit gracefully, killing")
|
||||
this.process.kill("SIGKILL")
|
||||
}
|
||||
resolve()
|
||||
}, 5000)
|
||||
|
||||
if (this.process) {
|
||||
this.process.once("exit", () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
this.process = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger indexing for a workspace path (non-blocking)
|
||||
*/
|
||||
async indexPath(path: string): Promise<void> {
|
||||
if (!this.config.lazyStart && !this.isReady()) {
|
||||
this.logger.debug({ path }, "Context-Engine not ready, skipping indexing")
|
||||
return
|
||||
}
|
||||
|
||||
// Lazy start if needed
|
||||
if (this.config.lazyStart && this.status === "stopped") {
|
||||
this.logger.info({ path }, "Lazy-starting Context-Engine for indexing")
|
||||
const started = await this.start()
|
||||
if (!started) {
|
||||
this.logger.warn({ path }, "Failed to start Context-Engine for indexing")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.indexingPaths.has(path)) {
|
||||
this.logger.debug({ path }, "Path already being indexed")
|
||||
return
|
||||
}
|
||||
|
||||
this.indexingPaths.add(path)
|
||||
this.setStatus("indexing")
|
||||
|
||||
// Fire and forget - don't block workspace creation
|
||||
this.client.index(path).then((response) => {
|
||||
this.indexingPaths.delete(path)
|
||||
if (response.status === "error") {
|
||||
this.logger.warn({ path, response }, "Context-Engine indexing failed")
|
||||
} else {
|
||||
this.logger.info({ path, indexed_files: response.indexed_files }, "Context-Engine indexing completed")
|
||||
}
|
||||
if (this.indexingPaths.size === 0 && this.status === "indexing") {
|
||||
this.setStatus("ready")
|
||||
}
|
||||
this.emit("indexComplete", { path, response })
|
||||
}).catch((error) => {
|
||||
this.indexingPaths.delete(path)
|
||||
this.logger.error({ path, error }, "Context-Engine indexing error")
|
||||
if (this.indexingPaths.size === 0 && this.status === "indexing") {
|
||||
this.setStatus("ready")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the Context-Engine for relevant code snippets
|
||||
*/
|
||||
async query(prompt: string, contextWindow?: number): Promise<string | null> {
|
||||
if (!this.isReady()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.query(prompt, contextWindow)
|
||||
if (response.results.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Format the results as a context block
|
||||
const contextParts = response.results.map((result, index) => {
|
||||
return `// File: ${result.file_path} (relevance: ${(result.score * 100).toFixed(1)}%)\n${result.content}`
|
||||
})
|
||||
|
||||
return `<context_engine_retrieval>\n${contextParts.join("\n\n")}\n</context_engine_retrieval>`
|
||||
} catch (error) {
|
||||
this.logger.warn({ error }, "Context-Engine query failed")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(status: ContextEngineStatus): void {
|
||||
if (this.status !== status) {
|
||||
this.logger.info({ oldStatus: this.status, newStatus: status }, "Context-Engine status changed")
|
||||
this.status = status
|
||||
this.emit("statusChange", status)
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForReady(timeoutMs: number): Promise<boolean> {
|
||||
const startTime = Date.now()
|
||||
const checkInterval = 500
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const health = await this.client.health()
|
||||
if (health.status === "healthy") {
|
||||
return true
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, checkInterval))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private startHealthCheck(): void {
|
||||
if (this.healthCheckTimer) return
|
||||
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const health = await this.client.health()
|
||||
if (health.status === "healthy") {
|
||||
this.healthCheckFailures = 0
|
||||
if (this.status === "error") {
|
||||
this.setStatus("ready")
|
||||
}
|
||||
} else {
|
||||
this.healthCheckFailures++
|
||||
if (this.healthCheckFailures >= this.config.maxHealthCheckRetries) {
|
||||
this.logger.warn("Context-Engine health check failed multiple times")
|
||||
this.setStatus("error")
|
||||
}
|
||||
}
|
||||
}, this.config.healthCheckInterval)
|
||||
}
|
||||
|
||||
private stopHealthCheck(): void {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer)
|
||||
this.healthCheckTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for global access
|
||||
let globalContextEngineService: ContextEngineService | null = null
|
||||
|
||||
export function getContextEngineService(): ContextEngineService | null {
|
||||
return globalContextEngineService
|
||||
}
|
||||
|
||||
export function initializeContextEngineService(
|
||||
config: Partial<ContextEngineServiceConfig>,
|
||||
logger: Logger
|
||||
): ContextEngineService {
|
||||
if (globalContextEngineService) {
|
||||
return globalContextEngineService
|
||||
}
|
||||
globalContextEngineService = new ContextEngineService(config, logger)
|
||||
return globalContextEngineService
|
||||
}
|
||||
|
||||
export async function shutdownContextEngineService(): Promise<void> {
|
||||
if (globalContextEngineService) {
|
||||
await globalContextEngineService.stop()
|
||||
globalContextEngineService = null
|
||||
}
|
||||
}
|
||||
47
packages/server/src/events/bus.ts
Normal file
47
packages/server/src/events/bus.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { WorkspaceEventPayload } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export class EventBus extends EventEmitter {
|
||||
constructor(private readonly logger?: Logger) {
|
||||
super()
|
||||
}
|
||||
|
||||
publish(event: WorkspaceEventPayload): boolean {
|
||||
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
||||
this.logger?.debug({ type: event.type }, "Publishing workspace event")
|
||||
if (this.logger?.isLevelEnabled("trace")) {
|
||||
this.logger.trace({ event }, "Workspace event payload")
|
||||
}
|
||||
}
|
||||
return super.emit(event.type, event)
|
||||
}
|
||||
|
||||
onEvent(listener: (event: WorkspaceEventPayload) => void) {
|
||||
const handler = (event: WorkspaceEventPayload) => listener(event)
|
||||
this.on("workspace.created", handler)
|
||||
this.on("workspace.started", handler)
|
||||
this.on("workspace.error", handler)
|
||||
this.on("workspace.stopped", handler)
|
||||
this.on("workspace.log", handler)
|
||||
this.on("config.appChanged", handler)
|
||||
this.on("config.binariesChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
this.on("app.releaseAvailable", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
this.off("workspace.error", handler)
|
||||
this.off("workspace.stopped", handler)
|
||||
this.off("workspace.log", handler)
|
||||
this.off("config.appChanged", handler)
|
||||
this.off("config.binariesChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
this.off("app.releaseAvailable", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { beforeEach, describe, it } from "node:test"
|
||||
import type { FileSystemEntry } from "../../api-types"
|
||||
import {
|
||||
clearWorkspaceSearchCache,
|
||||
getWorkspaceCandidates,
|
||||
refreshWorkspaceCandidates,
|
||||
WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
} from "../search-cache"
|
||||
|
||||
describe("workspace search cache", () => {
|
||||
beforeEach(() => {
|
||||
clearWorkspaceSearchCache()
|
||||
})
|
||||
|
||||
it("expires cached candidates after the TTL", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
const startTime = 1_000
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
|
||||
|
||||
const beforeExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
|
||||
)
|
||||
assert.ok(beforeExpiry)
|
||||
assert.equal(beforeExpiry.length, 1)
|
||||
assert.equal(beforeExpiry[0].name, "file-a")
|
||||
|
||||
const afterExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
|
||||
)
|
||||
assert.equal(afterExpiry, undefined)
|
||||
})
|
||||
|
||||
it("replaces cached entries when manually refreshed", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
|
||||
const initial = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(initial)
|
||||
assert.equal(initial[0].name, "file-a")
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
|
||||
const refreshed = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(refreshed)
|
||||
assert.equal(refreshed[0].name, "file-b")
|
||||
})
|
||||
})
|
||||
|
||||
function createEntry(name: string): FileSystemEntry {
|
||||
return {
|
||||
name,
|
||||
path: name,
|
||||
absolutePath: `/tmp/${name}`,
|
||||
type: "file",
|
||||
size: 1,
|
||||
modifiedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
295
packages/server/src/filesystem/browser.ts
Normal file
295
packages/server/src/filesystem/browser.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import {
|
||||
FileSystemEntry,
|
||||
FileSystemListResponse,
|
||||
FileSystemListingMetadata,
|
||||
WINDOWS_DRIVES_ROOT,
|
||||
} from "../api-types"
|
||||
|
||||
interface FileSystemBrowserOptions {
|
||||
rootDir: string
|
||||
unrestricted?: boolean
|
||||
}
|
||||
|
||||
interface DirectoryReadOptions {
|
||||
includeFiles: boolean
|
||||
formatPath: (entryName: string) => string
|
||||
formatAbsolutePath: (entryName: string) => string
|
||||
}
|
||||
|
||||
const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))
|
||||
|
||||
export class FileSystemBrowser {
|
||||
private readonly root: string
|
||||
private readonly unrestricted: boolean
|
||||
private readonly homeDir: string
|
||||
private readonly isWindows: boolean
|
||||
|
||||
constructor(options: FileSystemBrowserOptions) {
|
||||
this.root = path.resolve(options.rootDir)
|
||||
this.unrestricted = Boolean(options.unrestricted)
|
||||
this.homeDir = os.homedir()
|
||||
this.isWindows = process.platform === "win32"
|
||||
}
|
||||
|
||||
list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("Relative listing is unavailable when running with unrestricted root")
|
||||
}
|
||||
const includeFiles = options.includeFiles ?? true
|
||||
const normalizedPath = this.normalizeRelativePath(relativePath)
|
||||
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
|
||||
return this.readDirectoryEntries(absolutePath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
|
||||
})
|
||||
}
|
||||
|
||||
browse(targetPath?: string, options: { includeFiles?: boolean } = {}): FileSystemListResponse {
|
||||
const includeFiles = options.includeFiles ?? true
|
||||
if (this.unrestricted) {
|
||||
return this.listUnrestricted(targetPath, includeFiles)
|
||||
}
|
||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
}
|
||||
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||
return fs.readFileSync(resolved, "utf-8")
|
||||
}
|
||||
|
||||
private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse {
|
||||
const normalizedPath = this.normalizeRelativePath(relativePath)
|
||||
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
|
||||
const entries = this.readDirectoryEntries(absolutePath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
|
||||
})
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "restricted",
|
||||
currentPath: normalizedPath,
|
||||
parentPath: normalizedPath === "." ? undefined : this.getRestrictedParent(normalizedPath),
|
||||
rootPath: this.root,
|
||||
homePath: this.homeDir,
|
||||
displayPath: this.resolveRestrictedAbsolute(normalizedPath),
|
||||
pathKind: "relative",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private listUnrestricted(targetPath: string | undefined, includeFiles: boolean): FileSystemListResponse {
|
||||
const resolvedPath = this.resolveUnrestrictedPath(targetPath)
|
||||
|
||||
if (this.isWindows && resolvedPath === WINDOWS_DRIVES_ROOT) {
|
||||
return this.listWindowsDrives()
|
||||
}
|
||||
|
||||
const entries = this.readDirectoryEntries(resolvedPath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
|
||||
})
|
||||
|
||||
const parentPath = this.getUnrestrictedParent(resolvedPath)
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "unrestricted",
|
||||
currentPath: resolvedPath,
|
||||
parentPath,
|
||||
rootPath: this.homeDir,
|
||||
homePath: this.homeDir,
|
||||
displayPath: resolvedPath,
|
||||
pathKind: "absolute",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private listWindowsDrives(): FileSystemListResponse {
|
||||
if (!this.isWindows) {
|
||||
throw new Error("Drive listing is only supported on Windows hosts")
|
||||
}
|
||||
|
||||
const entries: FileSystemEntry[] = []
|
||||
for (const letter of WINDOWS_DRIVE_LETTERS) {
|
||||
const drivePath = `${letter}:\\`
|
||||
try {
|
||||
if (fs.existsSync(drivePath)) {
|
||||
entries.push({
|
||||
name: `${letter}:`,
|
||||
path: drivePath,
|
||||
absolutePath: drivePath,
|
||||
type: "directory",
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore inaccessible drives
|
||||
}
|
||||
}
|
||||
|
||||
// Provide a generic UNC root entry so users can navigate to network shares manually.
|
||||
entries.push({
|
||||
name: "UNC Network",
|
||||
path: "\\\\",
|
||||
absolutePath: "\\\\",
|
||||
type: "directory",
|
||||
})
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "unrestricted",
|
||||
currentPath: WINDOWS_DRIVES_ROOT,
|
||||
parentPath: undefined,
|
||||
rootPath: this.homeDir,
|
||||
homePath: this.homeDir,
|
||||
displayPath: "Drives",
|
||||
pathKind: "drives",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
for (const entry of dirents) {
|
||||
if (!options.includeFiles && !entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const absoluteEntryPath = path.join(directory, entry.name)
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(absoluteEntryPath)
|
||||
} catch {
|
||||
// Skip entries we cannot stat (insufficient permissions, etc.)
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = entry.isDirectory()
|
||||
if (!options.includeFiles && !isDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: entry.name,
|
||||
path: options.formatPath(entry.name),
|
||||
absolutePath: options.formatAbsolutePath(entry.name),
|
||||
type: isDirectory ? "directory" : "file",
|
||||
size: isDirectory ? undefined : stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return results.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
private normalizeRelativePath(input: string | undefined) {
|
||||
if (!input || input === "." || input === "./" || input === "/") {
|
||||
return "."
|
||||
}
|
||||
let normalized = input.replace(/\\+/g, "/")
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.replace(/^\/+/g, "")
|
||||
}
|
||||
return normalized === "" ? "." : normalized
|
||||
}
|
||||
|
||||
private buildRelativePath(parent: string, child: string) {
|
||||
if (!parent || parent === ".") {
|
||||
return this.normalizeRelativePath(child)
|
||||
}
|
||||
return this.normalizeRelativePath(`${parent}/${child}`)
|
||||
}
|
||||
|
||||
private resolveRestrictedAbsolute(relativePath: string) {
|
||||
return this.toRestrictedAbsolute(relativePath)
|
||||
}
|
||||
|
||||
private resolveRestrictedAbsoluteChild(parent: string, child: string) {
|
||||
const normalized = this.buildRelativePath(parent, child)
|
||||
return this.toRestrictedAbsolute(normalized)
|
||||
}
|
||||
|
||||
private toRestrictedAbsolute(relativePath: string) {
|
||||
const normalized = this.normalizeRelativePath(relativePath)
|
||||
const target = path.resolve(this.root, normalized)
|
||||
const relativeToRoot = path.relative(this.root, target)
|
||||
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) && relativeToRoot !== "") {
|
||||
throw new Error("Access outside of root is not allowed")
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private resolveUnrestrictedPath(input: string | undefined): string {
|
||||
if (!input || input === "." || input === "./") {
|
||||
return this.homeDir
|
||||
}
|
||||
|
||||
if (this.isWindows) {
|
||||
if (input === WINDOWS_DRIVES_ROOT) {
|
||||
return WINDOWS_DRIVES_ROOT
|
||||
}
|
||||
const normalized = path.win32.normalize(input)
|
||||
if (/^[a-zA-Z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||
return normalized
|
||||
}
|
||||
return path.win32.resolve(this.homeDir, normalized)
|
||||
}
|
||||
|
||||
if (input.startsWith("/")) {
|
||||
return path.posix.normalize(input)
|
||||
}
|
||||
|
||||
return path.posix.resolve(this.homeDir, input)
|
||||
}
|
||||
|
||||
private resolveAbsoluteChild(parent: string, child: string) {
|
||||
if (this.isWindows) {
|
||||
return path.win32.normalize(path.win32.join(parent, child))
|
||||
}
|
||||
return path.posix.normalize(path.posix.join(parent, child))
|
||||
}
|
||||
|
||||
private getRestrictedParent(relativePath: string) {
|
||||
const normalized = this.normalizeRelativePath(relativePath)
|
||||
if (normalized === ".") {
|
||||
return undefined
|
||||
}
|
||||
const segments = normalized.split("/")
|
||||
segments.pop()
|
||||
return segments.length === 0 ? "." : segments.join("/")
|
||||
}
|
||||
|
||||
private getUnrestrictedParent(currentPath: string) {
|
||||
if (this.isWindows) {
|
||||
const normalized = path.win32.normalize(currentPath)
|
||||
const parsed = path.win32.parse(normalized)
|
||||
if (normalized === WINDOWS_DRIVES_ROOT) {
|
||||
return undefined
|
||||
}
|
||||
if (normalized === parsed.root) {
|
||||
return WINDOWS_DRIVES_ROOT
|
||||
}
|
||||
return path.win32.dirname(normalized)
|
||||
}
|
||||
|
||||
const normalized = path.posix.normalize(currentPath)
|
||||
if (normalized === "/") {
|
||||
return undefined
|
||||
}
|
||||
return path.posix.dirname(normalized)
|
||||
}
|
||||
}
|
||||
66
packages/server/src/filesystem/search-cache.ts
Normal file
66
packages/server/src/filesystem/search-cache.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import path from "path"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
|
||||
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
|
||||
|
||||
interface WorkspaceCandidateCacheEntry {
|
||||
expiresAt: number
|
||||
candidates: FileSystemEntry[]
|
||||
}
|
||||
|
||||
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
|
||||
|
||||
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined {
|
||||
const key = normalizeKey(rootDir)
|
||||
const cached = workspaceCandidateCache.get(key)
|
||||
if (!cached) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (cached.expiresAt <= now) {
|
||||
workspaceCandidateCache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return cloneEntries(cached.candidates)
|
||||
}
|
||||
|
||||
export function refreshWorkspaceCandidates(
|
||||
rootDir: string,
|
||||
builder: () => FileSystemEntry[],
|
||||
now = Date.now(),
|
||||
): FileSystemEntry[] {
|
||||
const key = normalizeKey(rootDir)
|
||||
const freshCandidates = builder()
|
||||
|
||||
if (!freshCandidates || freshCandidates.length === 0) {
|
||||
workspaceCandidateCache.delete(key)
|
||||
return []
|
||||
}
|
||||
|
||||
const storedCandidates = cloneEntries(freshCandidates)
|
||||
workspaceCandidateCache.set(key, {
|
||||
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
candidates: storedCandidates,
|
||||
})
|
||||
|
||||
return cloneEntries(storedCandidates)
|
||||
}
|
||||
|
||||
export function clearWorkspaceSearchCache(rootDir?: string) {
|
||||
if (typeof rootDir === "undefined") {
|
||||
workspaceCandidateCache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeKey(rootDir)
|
||||
workspaceCandidateCache.delete(key)
|
||||
}
|
||||
|
||||
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
|
||||
return entries.map((entry) => ({ ...entry }))
|
||||
}
|
||||
|
||||
function normalizeKey(rootDir: string) {
|
||||
return path.resolve(rootDir)
|
||||
}
|
||||
184
packages/server/src/filesystem/search.ts
Normal file
184
packages/server/src/filesystem/search.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache"
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 200
|
||||
const MAX_CANDIDATES = 8000
|
||||
const IGNORED_DIRECTORIES = new Set(
|
||||
[".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map(
|
||||
(name) => name.toLowerCase(),
|
||||
),
|
||||
)
|
||||
|
||||
export type WorkspaceFileSearchType = "all" | "file" | "directory"
|
||||
|
||||
export interface WorkspaceFileSearchOptions {
|
||||
limit?: number
|
||||
type?: WorkspaceFileSearchType
|
||||
refresh?: boolean
|
||||
}
|
||||
|
||||
interface CandidateEntry {
|
||||
entry: FileSystemEntry
|
||||
key: string
|
||||
}
|
||||
|
||||
export function searchWorkspaceFiles(
|
||||
rootDir: string,
|
||||
query: string,
|
||||
options: WorkspaceFileSearchOptions = {},
|
||||
): FileSystemEntry[] {
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
throw new Error("Search query is required")
|
||||
}
|
||||
|
||||
const normalizedRoot = path.resolve(rootDir)
|
||||
const limit = normalizeLimit(options.limit)
|
||||
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
|
||||
const refreshRequested = options.refresh === true
|
||||
|
||||
let entries: FileSystemEntry[] | undefined
|
||||
|
||||
try {
|
||||
if (!refreshRequested) {
|
||||
entries = getWorkspaceCandidates(normalizedRoot)
|
||||
}
|
||||
|
||||
if (!entries) {
|
||||
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
|
||||
}
|
||||
} catch (error) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = buildCandidateEntries(entries, typeFilter)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches = fuzzysort.go<CandidateEntry>(trimmedQuery, candidates, {
|
||||
key: "key",
|
||||
limit,
|
||||
})
|
||||
|
||||
if (!matches || matches.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return matches.map((match) => match.obj.entry)
|
||||
}
|
||||
|
||||
|
||||
function collectCandidates(rootDir: string): FileSystemEntry[] {
|
||||
const queue: string[] = [""]
|
||||
const entries: FileSystemEntry[] = []
|
||||
|
||||
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
|
||||
const relativeDir = queue.pop() || ""
|
||||
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
|
||||
|
||||
let dirents: fs.Dirent[]
|
||||
try {
|
||||
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const entryName = dirent.name
|
||||
const lowerName = entryName.toLowerCase()
|
||||
const relativePath = relativeDir ? `${relativeDir}/${entryName}` : entryName
|
||||
const absolutePath = path.join(absoluteDir, entryName)
|
||||
|
||||
if (dirent.isDirectory() && IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(absolutePath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = stats.isDirectory()
|
||||
|
||||
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
if (entries.length < MAX_CANDIDATES) {
|
||||
queue.push(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
|
||||
const normalizedPath = normalizeRelativeEntryPath(relativePath)
|
||||
const entry: FileSystemEntry = {
|
||||
name: entryName,
|
||||
path: normalizedPath,
|
||||
absolutePath: path.resolve(rootDir, normalizedPath === "." ? "" : normalizedPath),
|
||||
type: entryType,
|
||||
size: entryType === "file" ? stats.size : undefined,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
}
|
||||
|
||||
entries.push(entry)
|
||||
|
||||
if (entries.length >= MAX_CANDIDATES) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function buildCandidateEntries(entries: FileSystemEntry[], filter: WorkspaceFileSearchType): CandidateEntry[] {
|
||||
const filtered: CandidateEntry[] = []
|
||||
for (const entry of entries) {
|
||||
if (!shouldInclude(entry.type, filter)) {
|
||||
continue
|
||||
}
|
||||
filtered.push({ entry, key: buildSearchKey(entry) })
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function normalizeLimit(limit?: number) {
|
||||
if (!limit || Number.isNaN(limit)) {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const clamped = Math.min(Math.max(limit, 1), MAX_LIMIT)
|
||||
return clamped
|
||||
}
|
||||
|
||||
function shouldInclude(entryType: FileSystemEntry["type"], filter: WorkspaceFileSearchType) {
|
||||
return filter === "all" || entryType === filter
|
||||
}
|
||||
|
||||
function normalizeRelativeEntryPath(relativePath: string): string {
|
||||
if (!relativePath) {
|
||||
return "."
|
||||
}
|
||||
let normalized = relativePath.replace(/\\+/g, "/")
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.replace(/^\/+/g, "")
|
||||
}
|
||||
return normalized || "."
|
||||
}
|
||||
|
||||
function buildSearchKey(entry: FileSystemEntry) {
|
||||
return entry.path.toLowerCase()
|
||||
}
|
||||
246
packages/server/src/index.ts
Normal file
246
packages/server/src/index.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* CLI entry point.
|
||||
* For now this only wires the typed modules together; actual command handling comes later.
|
||||
*/
|
||||
import { Command, InvalidArgumentError, Option } from "commander"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import { createHttpServer } from "./server/http-server"
|
||||
import { WorkspaceManager } from "./workspaces/manager"
|
||||
import { ConfigStore } from "./config/store"
|
||||
import { BinaryRegistry } from "./config/binaries"
|
||||
import { FileSystemBrowser } from "./filesystem/browser"
|
||||
import { EventBus } from "./events/bus"
|
||||
import { ServerMeta } from "./api-types"
|
||||
import { InstanceStore } from "./storage/instance-store"
|
||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { getUserConfigPath } from "./user-data"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { startReleaseMonitor } from "./releases/release-monitor"
|
||||
import { initializeContextEngineService, shutdownContextEngineService } from "./context-engine"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const packageJson = require("../package.json") as { version: string }
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
|
||||
interface CliOptions {
|
||||
port: number
|
||||
host: string
|
||||
rootDir: string
|
||||
configPath: string
|
||||
unrestrictedRoot: boolean
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
uiStaticDir: string
|
||||
uiDevServer?: string
|
||||
launch: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
const DEFAULT_HOST = "127.0.0.1"
|
||||
const DEFAULT_CONFIG_PATH = getUserConfigPath()
|
||||
|
||||
function parseCliOptions(argv: string[]): CliOptions {
|
||||
const program = new Command()
|
||||
.name("codenomad")
|
||||
.description("CodeNomad CLI server")
|
||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
|
||||
.addOption(
|
||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
)
|
||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||
.addOption(new Option("--config <path>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH))
|
||||
.addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL"))
|
||||
.addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
|
||||
.addOption(
|
||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||
)
|
||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
host: string
|
||||
port: number
|
||||
workspaceRoot?: string
|
||||
root?: string
|
||||
unrestrictedRoot?: boolean
|
||||
config: string
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
uiDir: string
|
||||
uiDevServer?: string
|
||||
launch?: boolean
|
||||
}>()
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
|
||||
const normalizedHost = resolveHost(parsed.host)
|
||||
|
||||
return {
|
||||
port: parsed.port,
|
||||
host: normalizedHost,
|
||||
rootDir: resolvedRoot,
|
||||
configPath: parsed.config,
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
logLevel: parsed.logLevel,
|
||||
logDestination: parsed.logDestination,
|
||||
uiStaticDir: parsed.uiDir,
|
||||
uiDevServer: parsed.uiDevServer,
|
||||
launch: Boolean(parsed.launch),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePort(input: string): number {
|
||||
const value = Number(input)
|
||||
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
||||
throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function resolveHost(input: string | undefined): string {
|
||||
if (input && input.trim() === "0.0.0.0") {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
return DEFAULT_HOST
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseCliOptions(process.argv.slice(2))
|
||||
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
|
||||
const workspaceLogger = logger.child({ component: "workspace" })
|
||||
const configLogger = logger.child({ component: "config" })
|
||||
const eventLogger = logger.child({ component: "events" })
|
||||
|
||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
rootDir: options.rootDir,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "instance-events" }),
|
||||
})
|
||||
|
||||
// Initialize Context-Engine service (lazy start - starts when first workspace opens)
|
||||
const contextEngineService = initializeContextEngineService(
|
||||
{
|
||||
lazyStart: true,
|
||||
port: 8000,
|
||||
host: "localhost",
|
||||
},
|
||||
logger.child({ component: "context-engine" })
|
||||
)
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
eventsUrl: `/api/events`,
|
||||
host: options.host,
|
||||
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
|
||||
port: options.port,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
addresses: [],
|
||||
}
|
||||
|
||||
const releaseMonitor = startReleaseMonitor({
|
||||
currentVersion: packageJson.version,
|
||||
logger: logger.child({ component: "release-monitor" }),
|
||||
onUpdate: (release) => {
|
||||
if (release) {
|
||||
serverMeta.latestRelease = release
|
||||
eventBus.publish({ type: "app.releaseAvailable", release })
|
||||
} else {
|
||||
delete serverMeta.latestRelease
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
uiStaticDir: options.uiStaticDir,
|
||||
uiDevServerUrl: options.uiDevServer,
|
||||
logger,
|
||||
})
|
||||
|
||||
const startInfo = await server.start()
|
||||
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
||||
|
||||
if (options.launch) {
|
||||
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) {
|
||||
logger.info("Shutdown already in progress, ignoring signal")
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
logger.info("Received shutdown signal, closing server")
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await shutdownContextEngineService()
|
||||
logger.info("Context-Engine shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Context-Engine shutdown failed")
|
||||
}
|
||||
|
||||
releaseMonitor.stop()
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", shutdown)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const logger = createLogger({ component: "app" })
|
||||
logger.error({ err: error }, "CLI server crashed")
|
||||
process.exit(1)
|
||||
})
|
||||
537
packages/server/src/integrations/ollama-cloud.ts
Normal file
537
packages/server/src/integrations/ollama-cloud.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { z } from "zod"
|
||||
import { getContextEngineService } from "../context-engine"
|
||||
|
||||
export const OllamaCloudConfigSchema = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
endpoint: z.string().default("https://ollama.com"),
|
||||
enabled: z.boolean().default(false)
|
||||
})
|
||||
|
||||
export type OllamaCloudConfig = z.infer<typeof OllamaCloudConfigSchema>
|
||||
|
||||
// Schema is flexible since Ollama Cloud may return different fields than local Ollama
|
||||
export const OllamaModelSchema = z.object({
|
||||
name: z.string(),
|
||||
model: z.string().optional(), // Some APIs return model instead of name
|
||||
size: z.union([z.string(), z.number()]).optional(),
|
||||
digest: z.string().optional(),
|
||||
modified_at: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
details: z.any().optional() // Model details like family, parameter_size, etc.
|
||||
})
|
||||
|
||||
export type OllamaModel = z.infer<typeof OllamaModelSchema>
|
||||
|
||||
export const ChatMessageSchema = z.object({
|
||||
role: z.enum(["user", "assistant", "system"]),
|
||||
content: z.string(),
|
||||
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 const ToolCallSchema = z.object({
|
||||
name: z.string(),
|
||||
arguments: z.record(z.any())
|
||||
})
|
||||
|
||||
export type ToolCall = z.infer<typeof ToolCallSchema>
|
||||
|
||||
export const ToolDefinitionSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
parameters: z.object({
|
||||
type: z.enum(["object", "string", "number", "boolean", "array"]),
|
||||
properties: z.record(z.any()),
|
||||
required: z.array(z.string()).optional()
|
||||
})
|
||||
})
|
||||
|
||||
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>
|
||||
|
||||
export const ChatRequestSchema = z.object({
|
||||
model: z.string(),
|
||||
messages: z.array(ChatMessageSchema),
|
||||
stream: z.boolean().default(false),
|
||||
think: z.union([z.boolean(), z.enum(["low", "medium", "high"])]).optional(),
|
||||
format: z.union([z.literal("json"), z.any()]).optional(),
|
||||
tools: z.array(ToolDefinitionSchema).optional(),
|
||||
web_search: z.boolean().optional(),
|
||||
options: z.object({
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
top_p: z.number().min(0).max(1).optional()
|
||||
}).optional()
|
||||
})
|
||||
|
||||
export const ChatResponseSchema = z.object({
|
||||
model: z.string(),
|
||||
created_at: z.string(),
|
||||
message: ChatMessageSchema.extend({
|
||||
thinking: z.string().optional(),
|
||||
tool_calls: z.array(z.any()).optional()
|
||||
}),
|
||||
done: z.boolean().optional(),
|
||||
total_duration: z.number().optional(),
|
||||
load_duration: z.number().optional(),
|
||||
prompt_eval_count: z.number().optional(),
|
||||
prompt_eval_duration: z.number().optional(),
|
||||
eval_count: z.number().optional(),
|
||||
eval_duration: z.number().optional()
|
||||
})
|
||||
|
||||
export type ChatRequest = z.infer<typeof ChatRequestSchema>
|
||||
export type ChatResponse = z.infer<typeof ChatResponseSchema>
|
||||
|
||||
export const EmbeddingRequestSchema = z.object({
|
||||
model: z.string(),
|
||||
input: z.union([z.string(), z.array(z.string())])
|
||||
})
|
||||
|
||||
export type EmbeddingRequest = z.infer<typeof EmbeddingRequestSchema>
|
||||
|
||||
export const EmbeddingResponseSchema = z.object({
|
||||
model: z.string(),
|
||||
embeddings: z.array(z.array(z.number()))
|
||||
})
|
||||
|
||||
export type EmbeddingResponse = z.infer<typeof EmbeddingResponseSchema>
|
||||
|
||||
export class OllamaCloudClient {
|
||||
private config: OllamaCloudConfig
|
||||
private baseUrl: string
|
||||
|
||||
constructor(config: OllamaCloudConfig) {
|
||||
this.config = config
|
||||
this.baseUrl = config.endpoint.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.makeRequest("/tags", { method: "GET" })
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
console.error("Ollama Cloud connection test failed:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(): Promise<OllamaModel[]> {
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
if (this.config.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
|
||||
const cloudResponse = await fetch(`${this.baseUrl}/v1/models`, {
|
||||
method: "GET",
|
||||
headers
|
||||
})
|
||||
|
||||
if (cloudResponse.ok) {
|
||||
const data = await cloudResponse.json()
|
||||
const modelsArray = Array.isArray(data?.data) ? data.data : []
|
||||
const parsedModels = modelsArray
|
||||
.map((model: any) => ({
|
||||
name: model.id || model.name || model.model,
|
||||
model: model.id || model.model || model.name,
|
||||
}))
|
||||
.filter((model: any) => model.name)
|
||||
|
||||
if (parsedModels.length > 0) {
|
||||
return parsedModels
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.makeRequest("/tags", { method: "GET" })
|
||||
|
||||
if (!response.ok) {
|
||||
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()
|
||||
console.log("[OllamaCloud] Models response:", JSON.stringify(data).substring(0, 500))
|
||||
|
||||
// Handle different response formats flexibly
|
||||
const modelsArray = Array.isArray(data.models) ? data.models :
|
||||
Array.isArray(data) ? data : []
|
||||
|
||||
// Parse with flexible schema, don't throw on validation failure
|
||||
// Only include cloud-compatible models (ending in -cloud or known cloud models)
|
||||
const parsedModels: OllamaModel[] = []
|
||||
for (const model of modelsArray) {
|
||||
try {
|
||||
const modelName = model.name || model.model || ""
|
||||
// Filter to only cloud-compatible models
|
||||
const isCloudModel = modelName.endsWith("-cloud") ||
|
||||
modelName.includes(":cloud") ||
|
||||
modelName.startsWith("gpt-oss") ||
|
||||
modelName.startsWith("qwen3-coder") ||
|
||||
modelName.startsWith("deepseek-v3")
|
||||
|
||||
if (modelName && isCloudModel) {
|
||||
parsedModels.push({
|
||||
name: modelName,
|
||||
model: model.model || modelName,
|
||||
size: model.size,
|
||||
digest: model.digest,
|
||||
modified_at: model.modified_at,
|
||||
created_at: model.created_at,
|
||||
details: model.details
|
||||
})
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn("[OllamaCloud] Skipping model due to parse error:", model, parseError)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[OllamaCloud] Parsed ${parsedModels.length} cloud-compatible models`)
|
||||
return parsedModels
|
||||
} catch (error) {
|
||||
console.error("Failed to list Ollama Cloud models:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async chat(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
|
||||
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}`
|
||||
}
|
||||
|
||||
// Inject Context-Engine RAG context if available
|
||||
let enrichedRequest = request
|
||||
try {
|
||||
const contextEngine = getContextEngineService()
|
||||
if (contextEngine?.isReady()) {
|
||||
// Get the last user message for context retrieval
|
||||
const lastUserMessage = [...request.messages].reverse().find(m => m.role === "user")
|
||||
if (lastUserMessage?.content) {
|
||||
const contextBlock = await contextEngine.query(lastUserMessage.content, 4096)
|
||||
if (contextBlock) {
|
||||
// Clone messages and inject context into the last user message
|
||||
const messagesWithContext = request.messages.map((msg, index) => {
|
||||
if (msg === lastUserMessage) {
|
||||
return {
|
||||
...msg,
|
||||
content: `${contextBlock}\n\n${msg.content}`
|
||||
}
|
||||
}
|
||||
return msg
|
||||
})
|
||||
enrichedRequest = { ...request, messages: messagesWithContext }
|
||||
console.log("[OllamaCloud] Context-Engine context injected")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (contextError) {
|
||||
// Graceful fallback - continue without context if Context-Engine fails
|
||||
console.warn("[OllamaCloud] Context-Engine query failed, continuing without RAG context:", contextError)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest("/chat", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(enrichedRequest)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Chat request failed: ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
if (request.stream) {
|
||||
return this.parseStreamingResponse(response)
|
||||
} else {
|
||||
const data = ChatResponseSchema.parse(await response.json())
|
||||
return this.createAsyncIterable([data])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ollama Cloud chat request failed:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async chatWithThinking(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
|
||||
const requestWithThinking = {
|
||||
...request,
|
||||
think: true
|
||||
}
|
||||
return this.chat(requestWithThinking)
|
||||
}
|
||||
|
||||
async chatWithStructuredOutput(request: ChatRequest, schema: any): Promise<AsyncIterable<ChatResponse>> {
|
||||
const requestWithFormat = {
|
||||
...request,
|
||||
format: schema
|
||||
}
|
||||
return this.chat(requestWithFormat)
|
||||
}
|
||||
|
||||
async chatWithVision(request: ChatRequest, images: string[]): Promise<AsyncIterable<ChatResponse>> {
|
||||
if (!request.messages.length) {
|
||||
throw new Error("At least one message is required")
|
||||
}
|
||||
|
||||
const messagesWithImages = [...request.messages]
|
||||
const lastUserMessage = messagesWithImages.slice().reverse().find(m => m.role === "user")
|
||||
|
||||
if (lastUserMessage) {
|
||||
lastUserMessage.images = images
|
||||
}
|
||||
|
||||
return this.chat({ ...request, messages: messagesWithImages })
|
||||
}
|
||||
|
||||
async chatWithTools(request: ChatRequest, tools: ToolDefinition[]): Promise<AsyncIterable<ChatResponse>> {
|
||||
const requestWithTools = {
|
||||
...request,
|
||||
tools
|
||||
}
|
||||
return this.chat(requestWithTools)
|
||||
}
|
||||
|
||||
async chatWithWebSearch(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
|
||||
const requestWithWebSearch = {
|
||||
...request,
|
||||
web_search: true
|
||||
}
|
||||
return this.chat(requestWithWebSearch)
|
||||
}
|
||||
|
||||
async generateEmbeddings(request: EmbeddingRequest): Promise<EmbeddingResponse> {
|
||||
if (!this.config.apiKey) {
|
||||
throw new Error("Ollama Cloud API key is required")
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
if (this.config.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest("/embed", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(request)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Embeddings request failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return EmbeddingResponseSchema.parse(data)
|
||||
} catch (error) {
|
||||
console.error("Ollama Cloud embeddings request failed:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async pullModel(modelName: string): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
if (this.config.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
|
||||
const response = await this.makeRequest("/pull", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name: modelName })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to pull model ${modelName}: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async *parseStreamingResponse(response: Response): AsyncIterable<ChatResponse> {
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is missing")
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const STREAM_TIMEOUT_MS = 60000 // 60 second timeout per chunk
|
||||
let lastActivity = Date.now()
|
||||
|
||||
const checkTimeout = () => {
|
||||
if (Date.now() - lastActivity > STREAM_TIMEOUT_MS) {
|
||||
reader.cancel().catch(() => { })
|
||||
throw new Error("Stream timeout - no data received for 60 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
checkTimeout()
|
||||
|
||||
// Create a timeout promise
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Read timeout")), STREAM_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
// Race the read against the timeout
|
||||
let result: ReadableStreamReadResult<Uint8Array>
|
||||
try {
|
||||
result = await Promise.race([reader.read(), timeoutPromise])
|
||||
} catch (timeoutError) {
|
||||
reader.cancel().catch(() => { })
|
||||
throw new Error("Stream read timeout")
|
||||
}
|
||||
|
||||
const { done, value } = result
|
||||
if (done) break
|
||||
|
||||
lastActivity = Date.now()
|
||||
|
||||
const lines = decoder.decode(value, { stream: true }).split('\n').filter(line => line.trim())
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line)
|
||||
const chatResponse = ChatResponseSchema.parse(data)
|
||||
yield chatResponse
|
||||
|
||||
if (chatResponse.done) {
|
||||
return
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn("Failed to parse streaming line:", line, parseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
private async *createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
|
||||
for (const item of items) {
|
||||
yield item
|
||||
}
|
||||
}
|
||||
|
||||
private async makeRequest(endpoint: string, options: RequestInit, timeoutMs: number = 120000): Promise<Response> {
|
||||
// Ensure endpoint starts with /api
|
||||
const apiEndpoint = endpoint.startsWith('/api') ? endpoint : `/api${endpoint}`
|
||||
const url = `${this.baseUrl}${apiEndpoint}`
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers as Record<string, string>
|
||||
}
|
||||
|
||||
if (this.config.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
|
||||
console.log(`[OllamaCloud] Making request to: ${url}`)
|
||||
|
||||
// Add timeout to prevent indefinite hangs
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
signal: controller.signal
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
async getCloudModels(): Promise<OllamaModel[]> {
|
||||
const allModels = await this.listModels()
|
||||
return allModels.filter(model => model.name.endsWith("-cloud"))
|
||||
}
|
||||
|
||||
static validateApiKey(apiKey: string): boolean {
|
||||
return typeof apiKey === "string" && apiKey.length > 0
|
||||
}
|
||||
|
||||
async getCloudModelNames(): Promise<string[]> {
|
||||
const cloudModels = await this.getCloudModels()
|
||||
return cloudModels.map(model => model.name)
|
||||
}
|
||||
|
||||
async getThinkingCapableModels(): Promise<string[]> {
|
||||
const allModels = await this.listModels()
|
||||
const thinkingModelPatterns = ["qwen3", "deepseek-r1", "gpt-oss", "deepseek-v3.1"]
|
||||
return allModels
|
||||
.map(m => m.name)
|
||||
.filter(name => thinkingModelPatterns.some(pattern => name.toLowerCase().includes(pattern)))
|
||||
}
|
||||
|
||||
async getVisionCapableModels(): Promise<string[]> {
|
||||
const allModels = await this.listModels()
|
||||
const visionModelPatterns = ["gemma3", "llama3.2-vision", "llava", "bakllava", "minicpm-v"]
|
||||
return allModels
|
||||
.map(m => m.name)
|
||||
.filter(name => visionModelPatterns.some(pattern => name.toLowerCase().includes(pattern)))
|
||||
}
|
||||
|
||||
async getEmbeddingModels(): Promise<string[]> {
|
||||
const allModels = await this.listModels()
|
||||
const embeddingModelPatterns = ["embeddinggemma", "qwen3-embedding", "all-minilm", "nomic-embed", "mxbai-embed"]
|
||||
return allModels
|
||||
.map(m => m.name)
|
||||
.filter(name => embeddingModelPatterns.some(pattern => name.toLowerCase().includes(pattern)))
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_CLOUD_MODELS = [
|
||||
"gpt-oss:120b-cloud",
|
||||
"llama3.1:70b-cloud",
|
||||
"llama3.1:8b-cloud",
|
||||
"qwen2.5:32b-cloud",
|
||||
"qwen2.5:7b-cloud"
|
||||
] as const
|
||||
|
||||
export type CloudModelName = typeof DEFAULT_CLOUD_MODELS[number]
|
||||
|
||||
export const THINKING_MODELS = [
|
||||
"qwen3",
|
||||
"deepseek-r1",
|
||||
"deepseek-v3.1",
|
||||
"gpt-oss:120b-cloud"
|
||||
] as const
|
||||
|
||||
export type ThinkingModelName = typeof THINKING_MODELS[number]
|
||||
|
||||
export const VISION_MODELS = [
|
||||
"gemma3",
|
||||
"llava",
|
||||
"bakllava",
|
||||
"minicpm-v"
|
||||
] as const
|
||||
|
||||
export type VisionModelName = typeof VISION_MODELS[number]
|
||||
|
||||
export const EMBEDDING_MODELS = [
|
||||
"embeddinggemma",
|
||||
"qwen3-embedding",
|
||||
"all-minilm",
|
||||
"nomic-embed-text",
|
||||
"mxbai-embed-large"
|
||||
] as const
|
||||
|
||||
export type EmbeddingModelName = typeof EMBEDDING_MODELS[number]
|
||||
370
packages/server/src/integrations/opencode-zen.ts
Normal file
370
packages/server/src/integrations/opencode-zen.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* OpenCode Zen API Integration
|
||||
* Provides direct access to OpenCode's free "Zen" models without requiring opencode.exe
|
||||
* Based on reverse-engineering the OpenCode source at https://github.com/sst/opencode
|
||||
*
|
||||
* Free models (cost.input === 0) can be accessed with apiKey: "public"
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
// Configuration schema for OpenCode Zen
|
||||
export const OpenCodeZenConfigSchema = z.object({
|
||||
enabled: z.boolean().default(true), // Free models enabled by default
|
||||
endpoint: z.string().default("https://opencode.ai/zen/v1"),
|
||||
apiKey: z.string().optional()
|
||||
})
|
||||
|
||||
export type OpenCodeZenConfig = z.infer<typeof OpenCodeZenConfigSchema>
|
||||
|
||||
// Model schema matching models.dev format
|
||||
export const ZenModelSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
family: z.string().optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
tool_call: z.boolean().optional(),
|
||||
attachment: z.boolean().optional(),
|
||||
temperature: z.boolean().optional(),
|
||||
cost: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional()
|
||||
}).optional(),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
output: z.number()
|
||||
}).optional()
|
||||
})
|
||||
|
||||
export type ZenModel = z.infer<typeof ZenModelSchema>
|
||||
|
||||
// Chat message schema (OpenAI-compatible)
|
||||
export const ChatMessageSchema = z.object({
|
||||
role: z.enum(["user", "assistant", "system", "tool"]),
|
||||
content: z.string().optional(),
|
||||
tool_calls: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
arguments: z.string()
|
||||
})
|
||||
})).optional(),
|
||||
tool_call_id: z.string().optional()
|
||||
})
|
||||
|
||||
export type ChatMessage = z.infer<typeof ChatMessageSchema>
|
||||
|
||||
// Chat request schema
|
||||
// Tool Definition Schema
|
||||
export const ToolDefinitionSchema = z.object({
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
parameters: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.any()),
|
||||
required: z.array(z.string()).optional()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>
|
||||
|
||||
export const ChatRequestSchema = z.object({
|
||||
model: z.string(),
|
||||
messages: z.array(ChatMessageSchema),
|
||||
stream: z.boolean().default(true),
|
||||
temperature: z.number().optional(),
|
||||
max_tokens: z.number().optional(),
|
||||
tools: z.array(ToolDefinitionSchema).optional(),
|
||||
tool_choice: z.union([
|
||||
z.literal("auto"),
|
||||
z.literal("none"),
|
||||
z.object({
|
||||
type: z.literal("function"),
|
||||
function: z.object({ name: z.string() })
|
||||
})
|
||||
]).optional(),
|
||||
workspacePath: z.string().optional(),
|
||||
enableTools: z.boolean().optional()
|
||||
})
|
||||
|
||||
export type ChatRequest = z.infer<typeof ChatRequestSchema>
|
||||
|
||||
// Chat response chunk schema
|
||||
export const ChatChunkSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
object: z.string().optional(),
|
||||
created: z.number().optional(),
|
||||
model: z.string().optional(),
|
||||
choices: z.array(z.object({
|
||||
index: z.number(),
|
||||
delta: z.object({
|
||||
role: z.string().optional(),
|
||||
content: z.string().optional()
|
||||
}).optional(),
|
||||
message: z.object({
|
||||
role: z.string(),
|
||||
content: z.string()
|
||||
}).optional(),
|
||||
finish_reason: z.string().nullable().optional()
|
||||
}))
|
||||
})
|
||||
|
||||
export type ChatChunk = z.infer<typeof ChatChunkSchema>
|
||||
|
||||
// Known free OpenCode Zen models (cost.input === 0)
|
||||
// From models.dev API - these are the free tier models
|
||||
export const FREE_ZEN_MODELS: ZenModel[] = [
|
||||
{
|
||||
id: "gpt-5-nano",
|
||||
name: "GPT-5 Nano",
|
||||
family: "gpt-5-nano",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
attachment: true,
|
||||
temperature: false,
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 400000, output: 128000 }
|
||||
},
|
||||
{
|
||||
id: "big-pickle",
|
||||
name: "Big Pickle",
|
||||
family: "pickle",
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
attachment: false,
|
||||
temperature: true,
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 200000, output: 128000 }
|
||||
},
|
||||
{
|
||||
id: "grok-code",
|
||||
name: "Grok Code Fast 1",
|
||||
family: "grok",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
attachment: false,
|
||||
temperature: true,
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 256000, output: 256000 }
|
||||
},
|
||||
{
|
||||
id: "glm-4.7-free",
|
||||
name: "GLM-4.7",
|
||||
family: "glm-free",
|
||||
reasoning: true,
|
||||
tool_call: true,
|
||||
attachment: false,
|
||||
temperature: true,
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 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 }
|
||||
}
|
||||
]
|
||||
|
||||
export class OpenCodeZenClient {
|
||||
private config: OpenCodeZenConfig
|
||||
private baseUrl: string
|
||||
private modelsCache: ZenModel[] | null = null
|
||||
private modelsCacheTime: number = 0
|
||||
private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
constructor(config?: Partial<OpenCodeZenConfig>) {
|
||||
this.config = OpenCodeZenConfigSchema.parse(config || {})
|
||||
this.baseUrl = this.config.endpoint.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get free Zen models from OpenCode
|
||||
*/
|
||||
async getModels(): Promise<ZenModel[]> {
|
||||
// Return cached models if still valid
|
||||
const now = Date.now()
|
||||
if (this.modelsCache && (now - this.modelsCacheTime) < this.CACHE_TTL_MS) {
|
||||
return this.modelsCache
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to fetch fresh models from models.dev
|
||||
const response = await fetch("https://models.dev/api.json", {
|
||||
headers: {
|
||||
"User-Agent": "NomadArch/1.0"
|
||||
},
|
||||
signal: AbortSignal.timeout(10000)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Extract OpenCode provider and filter free models
|
||||
const opencodeProvider = data["opencode"]
|
||||
if (opencodeProvider && opencodeProvider.models) {
|
||||
const freeModels: ZenModel[] = []
|
||||
for (const [id, model] of Object.entries(opencodeProvider.models)) {
|
||||
const m = model as any
|
||||
if (m.cost && m.cost.input === 0) {
|
||||
freeModels.push({
|
||||
id,
|
||||
name: m.name,
|
||||
family: m.family,
|
||||
reasoning: m.reasoning,
|
||||
tool_call: m.tool_call,
|
||||
attachment: m.attachment,
|
||||
temperature: m.temperature,
|
||||
cost: m.cost,
|
||||
limit: m.limit
|
||||
})
|
||||
}
|
||||
}
|
||||
if (freeModels.length > 0) {
|
||||
this.modelsCache = freeModels
|
||||
this.modelsCacheTime = now
|
||||
return freeModels
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch models from models.dev, using fallback:", error)
|
||||
}
|
||||
|
||||
// Fallback to hardcoded free models
|
||||
this.modelsCache = FREE_ZEN_MODELS
|
||||
this.modelsCacheTime = now
|
||||
return FREE_ZEN_MODELS
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to OpenCode Zen API
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const models = await this.getModels()
|
||||
return models.length > 0
|
||||
} catch (error) {
|
||||
console.error("OpenCode Zen connection test failed:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat completion (streaming)
|
||||
*/
|
||||
async *chatStream(request: ChatRequest): AsyncGenerator<ChatChunk> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "NomadArch/1.0",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "NomadArch"
|
||||
}
|
||||
if (this.config.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...request,
|
||||
stream: true
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`OpenCode Zen API error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is missing")
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith("data: ")) {
|
||||
const data = trimmed.slice(6)
|
||||
if (data === "[DONE]") return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
yield parsed as ChatChunk
|
||||
|
||||
// Check for finish
|
||||
if (parsed.choices?.[0]?.finish_reason) {
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat completion (non-streaming)
|
||||
*/
|
||||
async chat(request: ChatRequest): Promise<ChatChunk> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "NomadArch/1.0",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "NomadArch"
|
||||
}
|
||||
if (this.config.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...request,
|
||||
stream: false
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`OpenCode Zen API error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultZenConfig(): OpenCodeZenConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
endpoint: "https://opencode.ai/zen/v1"
|
||||
}
|
||||
}
|
||||
309
packages/server/src/integrations/zai-api.ts
Normal file
309
packages/server/src/integrations/zai-api.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { z } from "zod"
|
||||
import { createHmac } from "crypto"
|
||||
|
||||
export const ZAIConfigSchema = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
endpoint: z.string().default("https://api.z.ai/api/coding/paas/v4"),
|
||||
enabled: z.boolean().default(false),
|
||||
timeout: z.number().default(300000)
|
||||
})
|
||||
|
||||
export type ZAIConfig = z.infer<typeof ZAIConfigSchema>
|
||||
|
||||
export const ZAIMessageSchema = z.object({
|
||||
role: z.enum(["user", "assistant", "system", "tool"]),
|
||||
content: z.string().optional(),
|
||||
tool_calls: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
arguments: z.string()
|
||||
})
|
||||
})).optional(),
|
||||
tool_call_id: z.string().optional()
|
||||
})
|
||||
|
||||
export type ZAIMessage = z.infer<typeof ZAIMessageSchema>
|
||||
|
||||
// Tool Definition Schema (OpenAI-compatible)
|
||||
export const ZAIToolSchema = z.object({
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
parameters: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.object({
|
||||
type: z.string(),
|
||||
description: z.string().optional()
|
||||
})),
|
||||
required: z.array(z.string()).optional()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export type ZAITool = z.infer<typeof ZAIToolSchema>
|
||||
|
||||
export const ZAIChatRequestSchema = z.object({
|
||||
model: z.string().default("glm-4.7"),
|
||||
messages: z.array(ZAIMessageSchema),
|
||||
max_tokens: z.number().default(8192),
|
||||
stream: z.boolean().default(true),
|
||||
temperature: z.number().optional(),
|
||||
tools: z.array(ZAIToolSchema).optional(),
|
||||
tool_choice: z.union([
|
||||
z.literal("auto"),
|
||||
z.literal("none"),
|
||||
z.object({
|
||||
type: z.literal("function"),
|
||||
function: z.object({ name: z.string() })
|
||||
})
|
||||
]).optional(),
|
||||
thinking: z.object({
|
||||
type: z.enum(["enabled", "disabled"]).optional()
|
||||
}).optional()
|
||||
})
|
||||
|
||||
export type ZAIChatRequest = z.infer<typeof ZAIChatRequestSchema>
|
||||
|
||||
export const ZAIChatResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
object: z.string(),
|
||||
created: z.number(),
|
||||
model: z.string(),
|
||||
choices: z.array(z.object({
|
||||
index: z.number(),
|
||||
message: z.object({
|
||||
role: z.string(),
|
||||
content: z.string().optional().nullable(),
|
||||
reasoning_content: z.string().optional(),
|
||||
tool_calls: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
arguments: z.string()
|
||||
})
|
||||
})).optional()
|
||||
}),
|
||||
finish_reason: z.string()
|
||||
})),
|
||||
usage: z.object({
|
||||
prompt_tokens: z.number(),
|
||||
completion_tokens: z.number(),
|
||||
total_tokens: z.number()
|
||||
})
|
||||
})
|
||||
|
||||
export type ZAIChatResponse = z.infer<typeof ZAIChatResponseSchema>
|
||||
|
||||
export const ZAIStreamChunkSchema = z.object({
|
||||
id: z.string(),
|
||||
object: z.string(),
|
||||
created: z.number(),
|
||||
model: z.string(),
|
||||
choices: z.array(z.object({
|
||||
index: z.number(),
|
||||
delta: z.object({
|
||||
role: z.string().optional(),
|
||||
content: z.string().optional().nullable(),
|
||||
reasoning_content: z.string().optional(),
|
||||
tool_calls: z.array(z.object({
|
||||
index: z.number().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.literal("function").optional(),
|
||||
function: z.object({
|
||||
name: z.string().optional(),
|
||||
arguments: z.string().optional()
|
||||
}).optional()
|
||||
})).optional()
|
||||
}),
|
||||
finish_reason: z.string().nullable().optional()
|
||||
}))
|
||||
})
|
||||
|
||||
export type ZAIStreamChunk = z.infer<typeof ZAIStreamChunkSchema>
|
||||
|
||||
export const ZAI_MODELS = [
|
||||
"glm-4.7",
|
||||
"glm-4.6",
|
||||
"glm-4.5",
|
||||
"glm-4.5-air",
|
||||
"glm-4.5-flash",
|
||||
"glm-4.5-long"
|
||||
] as const
|
||||
|
||||
export type ZAIModelName = typeof ZAI_MODELS[number]
|
||||
|
||||
export class ZAIClient {
|
||||
private config: ZAIConfig
|
||||
private baseUrl: string
|
||||
|
||||
constructor(config: ZAIConfig) {
|
||||
this.config = config
|
||||
this.baseUrl = config.endpoint.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
if (!this.config.apiKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: "glm-4.7",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "test" }]
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
console.error(`Z.AI connection failed (${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
console.error("Z.AI connection test failed:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(): Promise<string[]> {
|
||||
return [...ZAI_MODELS]
|
||||
}
|
||||
|
||||
async *chatStream(request: ZAIChatRequest): AsyncGenerator<ZAIStreamChunk> {
|
||||
if (!this.config.apiKey) {
|
||||
throw new Error("Z.AI API key is required")
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
...request,
|
||||
stream: true
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Z.AI API error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is missing")
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim()
|
||||
if (data === "[DONE]") return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
yield parsed as ZAIStreamChunk
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
async chat(request: ZAIChatRequest): Promise<ZAIChatResponse> {
|
||||
if (!this.config.apiKey) {
|
||||
throw new Error("Z.AI API key is required")
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
...request,
|
||||
stream: false
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Z.AI API error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const token = this.generateToken(this.config.apiKey!)
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
private generateToken(apiKey: string, expiresIn: number = 3600): string {
|
||||
try {
|
||||
const [id, secret] = apiKey.split(".")
|
||||
if (!id || !secret) return apiKey // Fallback or handle error
|
||||
|
||||
const now = Date.now()
|
||||
const payload = {
|
||||
api_key: id,
|
||||
exp: now + expiresIn * 1000,
|
||||
timestamp: now
|
||||
}
|
||||
|
||||
const header = {
|
||||
alg: "HS256",
|
||||
sign_type: "SIGN"
|
||||
}
|
||||
|
||||
const base64UrlEncode = (obj: any) => {
|
||||
return Buffer.from(JSON.stringify(obj))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
const encodedHeader = base64UrlEncode(header)
|
||||
const encodedPayload = base64UrlEncode(payload)
|
||||
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(`${encodedHeader}.${encodedPayload}`)
|
||||
.digest("base64")
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
|
||||
return `${encodedHeader}.${encodedPayload}.${signature}`
|
||||
} catch (e) {
|
||||
console.warn("Failed to generate JWT, using raw key", e)
|
||||
return apiKey
|
||||
}
|
||||
}
|
||||
|
||||
static validateApiKey(apiKey: string): boolean {
|
||||
return typeof apiKey === "string" && apiKey.length > 0
|
||||
}
|
||||
}
|
||||
177
packages/server/src/launcher.ts
Normal file
177
packages/server/src/launcher.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { spawn } from "child_process"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Logger } from "./logger"
|
||||
|
||||
interface BrowserCandidate {
|
||||
name: string
|
||||
command: string
|
||||
args: (url: string) => string[]
|
||||
}
|
||||
|
||||
const APP_ARGS = (url: string) => [`--app=${url}`, "--new-window"]
|
||||
|
||||
export async function launchInBrowser(url: string, logger: Logger): Promise<boolean> {
|
||||
const { platform, candidates, manualExamples } = buildPlatformCandidates(url)
|
||||
|
||||
console.log(`Attempting to launch browser (${platform}) using:`)
|
||||
candidates.forEach((candidate) => console.log(` - ${candidate.name}: ${candidate.command}`))
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const success = await tryLaunch(candidate, url, logger)
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
"No supported browser found to launch. Run without --launch and use one of the commands below or install a compatible browser.",
|
||||
)
|
||||
if (manualExamples.length > 0) {
|
||||
console.error("Manual launch commands:")
|
||||
manualExamples.forEach((line) => console.error(` ${line}`))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function tryLaunch(candidate: BrowserCandidate, url: string, logger: Logger): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
try {
|
||||
const args = candidate.args(url)
|
||||
const child = spawn(candidate.command, args, { stdio: "ignore", detached: true })
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.debug({ err: error, candidate: candidate.name, command: candidate.command, args }, "Browser launch failed")
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
child.once("spawn", () => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.info(
|
||||
{
|
||||
browser: candidate.name,
|
||||
command: candidate.command,
|
||||
args,
|
||||
fullCommand: [candidate.command, ...args].join(" "),
|
||||
},
|
||||
"Launched browser in app mode",
|
||||
)
|
||||
child.unref()
|
||||
resolve(true)
|
||||
})
|
||||
} catch (error) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.debug({ err: error, candidate: candidate.name, command: candidate.command }, "Browser spawn threw")
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildPlatformCandidates(url: string) {
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
return {
|
||||
platform: "macOS",
|
||||
candidates: buildMacCandidates(),
|
||||
manualExamples: buildMacManualExamples(url),
|
||||
}
|
||||
case "win32":
|
||||
return {
|
||||
platform: "Windows",
|
||||
candidates: buildWindowsCandidates(),
|
||||
manualExamples: buildWindowsManualExamples(url),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
platform: "Linux",
|
||||
candidates: buildLinuxCandidates(),
|
||||
manualExamples: buildLinuxManualExamples(url),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMacCandidates(): BrowserCandidate[] {
|
||||
const apps = [
|
||||
{ name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
|
||||
{ name: "Google Chrome Canary", path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" },
|
||||
{ name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
|
||||
{ name: "Brave Browser", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
|
||||
{ name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
|
||||
{ name: "Vivaldi", path: "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" },
|
||||
{ name: "Arc", path: "/Applications/Arc.app/Contents/MacOS/Arc" },
|
||||
]
|
||||
|
||||
return apps.map((entry) => ({ name: entry.name, command: entry.path, args: APP_ARGS }))
|
||||
}
|
||||
|
||||
function buildWindowsCandidates(): BrowserCandidate[] {
|
||||
const programFiles = process.env["ProgramFiles"]
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"]
|
||||
const localAppData = process.env["LocalAppData"]
|
||||
|
||||
const paths = [
|
||||
[programFiles, "Google/Chrome/Application/chrome.exe", "Google Chrome"],
|
||||
[programFilesX86, "Google/Chrome/Application/chrome.exe", "Google Chrome (x86)"],
|
||||
[localAppData, "Google/Chrome/Application/chrome.exe", "Google Chrome (User)"],
|
||||
[programFiles, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"],
|
||||
[programFilesX86, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (x86)"],
|
||||
[localAppData, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (User)"],
|
||||
[programFiles, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave"],
|
||||
[localAppData, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave (User)"],
|
||||
[programFiles, "Chromium/Application/chromium.exe", "Chromium"],
|
||||
] as const
|
||||
|
||||
return paths
|
||||
.filter(([root]) => Boolean(root))
|
||||
.map(([root, rel, name]) => ({
|
||||
name,
|
||||
command: path.join(root as string, rel),
|
||||
args: APP_ARGS,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildLinuxCandidates(): BrowserCandidate[] {
|
||||
const names = [
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"brave-browser",
|
||||
"microsoft-edge",
|
||||
"microsoft-edge-stable",
|
||||
"vivaldi",
|
||||
]
|
||||
|
||||
return names.map((name) => ({ name, command: name, args: APP_ARGS }))
|
||||
}
|
||||
|
||||
function buildMacManualExamples(url: string) {
|
||||
return [
|
||||
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app="${url}" --new-window`,
|
||||
`"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" --app="${url}" --new-window`,
|
||||
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
|
||||
function buildWindowsManualExamples(url: string) {
|
||||
return [
|
||||
`"%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" --app="${url}" --new-window`,
|
||||
`"%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe" --app="${url}" --new-window`,
|
||||
`"%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
|
||||
function buildLinuxManualExamples(url: string) {
|
||||
return [
|
||||
`google-chrome --app="${url}" --new-window`,
|
||||
`chromium --app="${url}" --new-window`,
|
||||
`brave-browser --app="${url}" --new-window`,
|
||||
`microsoft-edge --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
21
packages/server/src/loader.ts
Normal file
21
packages/server/src/loader.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export async function resolve(specifier: string, context: any, defaultResolve: any) {
|
||||
try {
|
||||
return await defaultResolve(specifier, context, defaultResolve)
|
||||
} catch (error: any) {
|
||||
if (shouldRetry(specifier, error)) {
|
||||
const retried = specifier.endsWith(".js") ? specifier : `${specifier}.js`
|
||||
return defaultResolve(retried, context, defaultResolve)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetry(specifier: string, error: any) {
|
||||
if (!error || error.code !== "ERR_MODULE_NOT_FOUND") {
|
||||
return false
|
||||
}
|
||||
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
133
packages/server/src/logger.ts
Normal file
133
packages/server/src/logger.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Transform } from "node:stream"
|
||||
import pino, { Logger as PinoLogger } from "pino"
|
||||
|
||||
export type Logger = PinoLogger
|
||||
|
||||
interface LoggerOptions {
|
||||
level?: string
|
||||
destination?: string
|
||||
component?: string
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<number, string> = {
|
||||
10: "trace",
|
||||
20: "debug",
|
||||
30: "info",
|
||||
40: "warn",
|
||||
50: "error",
|
||||
60: "fatal",
|
||||
}
|
||||
|
||||
const LIFECYCLE_COMPONENTS = new Set(["app", "workspace"])
|
||||
const OMITTED_FIELDS = new Set(["time", "msg", "level", "component", "module"])
|
||||
|
||||
export function createLogger(options: LoggerOptions = {}): Logger {
|
||||
const level = (options.level ?? process.env.CLI_LOG_LEVEL ?? "info").toLowerCase()
|
||||
const destination = options.destination ?? process.env.CLI_LOG_DESTINATION ?? "stdout"
|
||||
const baseComponent = options.component ?? "app"
|
||||
const loggerOptions = {
|
||||
level,
|
||||
base: { component: baseComponent },
|
||||
timestamp: false,
|
||||
} as const
|
||||
|
||||
if (destination && destination !== "stdout") {
|
||||
const stream = pino.destination({ dest: destination, mkdir: true, sync: false })
|
||||
return pino(loggerOptions, stream)
|
||||
}
|
||||
|
||||
const lifecycleStream = new LifecycleLogStream({ restrictInfoToLifecycle: level === "info" })
|
||||
lifecycleStream.pipe(process.stdout)
|
||||
return pino(loggerOptions, lifecycleStream)
|
||||
}
|
||||
|
||||
interface LifecycleStreamOptions {
|
||||
restrictInfoToLifecycle: boolean
|
||||
}
|
||||
|
||||
class LifecycleLogStream extends Transform {
|
||||
private buffer = ""
|
||||
|
||||
constructor(private readonly options: LifecycleStreamOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: () => void) {
|
||||
this.buffer += chunk.toString()
|
||||
let newlineIndex = this.buffer.indexOf("\n")
|
||||
while (newlineIndex >= 0) {
|
||||
const line = this.buffer.slice(0, newlineIndex)
|
||||
this.buffer = this.buffer.slice(newlineIndex + 1)
|
||||
this.pushFormatted(line)
|
||||
newlineIndex = this.buffer.indexOf("\n")
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
_flush(callback: () => void) {
|
||||
if (this.buffer.length > 0) {
|
||||
this.pushFormatted(this.buffer)
|
||||
this.buffer = ""
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
private pushFormatted(line: string) {
|
||||
if (!line.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
let entry: Record<string, unknown>
|
||||
try {
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const levelNumber = typeof entry.level === "number" ? entry.level : 30
|
||||
const levelLabel = LEVEL_LABELS[levelNumber] ?? "info"
|
||||
const component = (entry.component as string | undefined) ?? (entry.module as string | undefined) ?? "app"
|
||||
|
||||
if (this.options.restrictInfoToLifecycle && levelNumber <= 30 && !LIFECYCLE_COMPONENTS.has(component)) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = typeof entry.msg === "string" ? entry.msg : ""
|
||||
const metadata = this.formatMetadata(entry)
|
||||
const formatted = metadata.length > 0 ? `[${levelLabel.toUpperCase()}] [${component}] ${message} ${metadata}` : `[${levelLabel.toUpperCase()}] [${component}] ${message}`
|
||||
this.push(`${formatted}\n`)
|
||||
}
|
||||
|
||||
private formatMetadata(entry: Record<string, unknown>): string {
|
||||
const pairs: string[] = []
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (OMITTED_FIELDS.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === "err" && value && typeof value === "object") {
|
||||
const err = value as { type?: string; message?: string; stack?: string }
|
||||
const errLabel = err.type ?? "Error"
|
||||
const errMessage = err.message ? `: ${err.message}` : ""
|
||||
pairs.push(`err=${errLabel}${errMessage}`)
|
||||
if (err.stack) {
|
||||
pairs.push(`stack="${err.stack}"`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pairs.push(`${key}=${this.stringifyValue(value)}`)
|
||||
}
|
||||
|
||||
return pairs.join(" ").trim()
|
||||
}
|
||||
|
||||
private stringifyValue(value: unknown): string {
|
||||
if (value === undefined) return "undefined"
|
||||
if (value === null) return "null"
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (value instanceof Error) return value.message ?? value.name
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
532
packages/server/src/mcp/client.ts
Normal file
532
packages/server/src/mcp/client.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* MCP Client - Connects to MCP (Model Context Protocol) servers
|
||||
* and provides tool discovery and execution capabilities.
|
||||
*
|
||||
* Supports:
|
||||
* - stdio-based MCP servers (command + args)
|
||||
* - HTTP/SSE-based remote MCP servers
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { createLogger } from "../logger"
|
||||
import path from "path"
|
||||
|
||||
const log = createLogger({ component: "mcp-client" })
|
||||
|
||||
// MCP Protocol Types
|
||||
export interface McpServerConfig {
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
type?: "stdio" | "remote" | "http" | "sse" | "streamable-http"
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface McpToolDefinition {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: {
|
||||
type: "object"
|
||||
properties: Record<string, { type: string; description?: string }>
|
||||
required?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpToolCall {
|
||||
name: string
|
||||
arguments: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface McpToolResult {
|
||||
content: Array<{
|
||||
type: "text" | "image" | "resource"
|
||||
text?: string
|
||||
data?: string
|
||||
mimeType?: string
|
||||
}>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
// MCP JSON-RPC Message Types
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: "2.0"
|
||||
id: number | string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: "2.0"
|
||||
id: number | string
|
||||
result?: unknown
|
||||
error?: { code: number; message: string; data?: unknown }
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Client for a single server
|
||||
*/
|
||||
export class McpClient {
|
||||
private config: McpServerConfig
|
||||
private process: ChildProcess | null = null
|
||||
private messageId = 0
|
||||
private pendingRequests: Map<number | string, {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: unknown) => void
|
||||
}> = new Map()
|
||||
private buffer = ""
|
||||
private tools: McpToolDefinition[] = []
|
||||
private connected = false
|
||||
private serverName: string
|
||||
|
||||
constructor(serverName: string, config: McpServerConfig) {
|
||||
this.serverName = serverName
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Start and connect to the MCP server
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) return
|
||||
|
||||
if (this.config.type === "remote" || this.config.type === "http" || this.config.type === "sse") {
|
||||
// HTTP-based server - just mark as connected
|
||||
this.connected = true
|
||||
log.info({ server: this.serverName, type: this.config.type }, "Connected to remote MCP server")
|
||||
return
|
||||
}
|
||||
|
||||
// Stdio-based server
|
||||
if (!this.config.command) {
|
||||
throw new Error(`MCP server ${this.serverName} has no command configured`)
|
||||
}
|
||||
|
||||
log.info({ server: this.serverName, command: this.config.command, args: this.config.args }, "Starting MCP server")
|
||||
|
||||
this.process = spawn(this.config.command, this.config.args || [], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, ...this.config.env },
|
||||
shell: true
|
||||
})
|
||||
|
||||
this.process.stdout?.on("data", (data) => this.handleData(data.toString()))
|
||||
this.process.stderr?.on("data", (data) => log.warn({ server: this.serverName }, `MCP stderr: ${data}`))
|
||||
this.process.on("error", (err) => log.error({ server: this.serverName, error: err }, "MCP process error"))
|
||||
this.process.on("exit", (code) => {
|
||||
log.info({ server: this.serverName, code }, "MCP process exited")
|
||||
this.connected = false
|
||||
})
|
||||
|
||||
// Wait for process to start
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Initialize the server
|
||||
try {
|
||||
await this.sendRequest("initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: { tools: {} },
|
||||
clientInfo: { name: "NomadArch", version: "0.4.0" }
|
||||
})
|
||||
|
||||
await this.sendRequest("notifications/initialized", {})
|
||||
this.connected = true
|
||||
log.info({ server: this.serverName }, "MCP server initialized")
|
||||
} catch (error) {
|
||||
log.error({ server: this.serverName, error }, "Failed to initialize MCP server")
|
||||
this.disconnect()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the MCP server
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.process) {
|
||||
this.process.kill()
|
||||
this.process = null
|
||||
}
|
||||
this.connected = false
|
||||
this.tools = []
|
||||
this.pendingRequests.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* List available tools from this MCP server
|
||||
*/
|
||||
async listTools(): Promise<McpToolDefinition[]> {
|
||||
if (!this.connected) {
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
if (this.config.type === "remote" || this.config.type === "http") {
|
||||
// For HTTP servers, fetch tools via HTTP
|
||||
return this.fetchToolsHttp()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.sendRequest("tools/list", {}) as { tools?: McpToolDefinition[] }
|
||||
this.tools = response.tools || []
|
||||
return this.tools
|
||||
} catch (error) {
|
||||
log.error({ server: this.serverName, error }, "Failed to list MCP tools")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool on this MCP server
|
||||
*/
|
||||
async executeTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
||||
if (!this.connected) {
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
log.info({ server: this.serverName, tool: name, args }, "Executing MCP tool")
|
||||
|
||||
if (this.config.type === "remote" || this.config.type === "http") {
|
||||
return this.executeToolHttp(name, args)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.sendRequest("tools/call", { name, arguments: args }) as McpToolResult
|
||||
return response
|
||||
} catch (error) {
|
||||
log.error({ server: this.serverName, tool: name, error }, "MCP tool execution failed")
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC request to the MCP server
|
||||
*/
|
||||
private async sendRequest(method: string, params?: unknown): Promise<unknown> {
|
||||
if (!this.process?.stdin) {
|
||||
throw new Error("MCP server not running")
|
||||
}
|
||||
|
||||
const id = ++this.messageId
|
||||
const request: JsonRpcRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, { resolve, reject })
|
||||
|
||||
const message = JSON.stringify(request) + "\n"
|
||||
this.process!.stdin!.write(message)
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new Error(`MCP request timeout: ${method}`))
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming data from the MCP server
|
||||
*/
|
||||
private handleData(data: string): void {
|
||||
this.buffer += data
|
||||
const lines = this.buffer.split("\n")
|
||||
this.buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const message = JSON.parse(line) as JsonRpcResponse
|
||||
if (message.id !== undefined && this.pendingRequests.has(message.id)) {
|
||||
const pending = this.pendingRequests.get(message.id)!
|
||||
this.pendingRequests.delete(message.id)
|
||||
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message))
|
||||
} else {
|
||||
pending.resolve(message.result)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn({ server: this.serverName }, `Failed to parse MCP message: ${line}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tools from HTTP-based MCP server
|
||||
*/
|
||||
private async fetchToolsHttp(): Promise<McpToolDefinition[]> {
|
||||
if (!this.config.url) return []
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.url}/tools/list`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.config.headers
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as JsonRpcResponse
|
||||
const result = data.result as { tools?: McpToolDefinition[] }
|
||||
return result.tools || []
|
||||
} catch (error) {
|
||||
log.error({ server: this.serverName, error }, "Failed to fetch HTTP MCP tools")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tool on HTTP-based MCP server
|
||||
*/
|
||||
private async executeToolHttp(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
||||
if (!this.config.url) {
|
||||
return { content: [{ type: "text", text: "No URL configured" }], isError: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.url}/tools/call`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.config.headers
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: args }
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as JsonRpcResponse
|
||||
return data.result as McpToolResult
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: `HTTP error: ${error instanceof Error ? error.message : String(error)}` }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
getServerName(): string {
|
||||
return this.serverName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Manager - Manages multiple MCP server connections
|
||||
*/
|
||||
export class McpManager {
|
||||
private clients: Map<string, McpClient> = new Map()
|
||||
private configPath: string | null = null
|
||||
|
||||
/**
|
||||
* Load MCP config from a workspace
|
||||
*/
|
||||
async loadConfig(workspacePath: string): Promise<void> {
|
||||
const configPath = path.join(workspacePath, ".mcp.json")
|
||||
this.configPath = configPath
|
||||
|
||||
try {
|
||||
const fs = await import("fs")
|
||||
if (!fs.existsSync(configPath)) {
|
||||
log.info({ path: configPath }, "No MCP config found")
|
||||
return
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(content) as { mcpServers?: Record<string, McpServerConfig> }
|
||||
|
||||
if (config.mcpServers) {
|
||||
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
this.addServer(name, serverConfig)
|
||||
}
|
||||
}
|
||||
|
||||
log.info({ servers: Object.keys(config.mcpServers || {}) }, "Loaded MCP config")
|
||||
} catch (error) {
|
||||
log.error({ path: configPath, error }, "Failed to load MCP config")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an MCP server
|
||||
*/
|
||||
addServer(name: string, config: McpServerConfig): void {
|
||||
if (this.clients.has(name)) {
|
||||
this.clients.get(name)!.disconnect()
|
||||
}
|
||||
this.clients.set(name, new McpClient(name, config))
|
||||
log.info({ server: name }, "Added MCP server")
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an MCP server
|
||||
*/
|
||||
removeServer(name: string): void {
|
||||
const client = this.clients.get(name)
|
||||
if (client) {
|
||||
client.disconnect()
|
||||
this.clients.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available tools from all connected servers
|
||||
*/
|
||||
async getAllTools(): Promise<Array<McpToolDefinition & { serverName: string }>> {
|
||||
const allTools: Array<McpToolDefinition & { serverName: string }> = []
|
||||
|
||||
for (const [name, client] of this.clients) {
|
||||
try {
|
||||
const tools = await client.listTools()
|
||||
for (const tool of tools) {
|
||||
allTools.push({ ...tool, serverName: name })
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn({ server: name, error }, "Failed to get tools from MCP server")
|
||||
}
|
||||
}
|
||||
|
||||
return allTools
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MCP tools to OpenAI-compatible format
|
||||
*/
|
||||
async getToolsAsOpenAIFormat(): Promise<Array<{
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
description: string
|
||||
parameters: McpToolDefinition["inputSchema"]
|
||||
}
|
||||
}>> {
|
||||
const mcpTools = await this.getAllTools()
|
||||
|
||||
return mcpTools.map(tool => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
// Prefix with server name to avoid conflicts
|
||||
name: `mcp_${tool.serverName}_${tool.name}`,
|
||||
description: `[MCP: ${tool.serverName}] ${tool.description}`,
|
||||
parameters: tool.inputSchema
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool by its full name (mcp_servername_toolname)
|
||||
*/
|
||||
async executeTool(fullName: string, args: Record<string, unknown>): Promise<string> {
|
||||
// Parse mcp_servername_toolname format
|
||||
const match = fullName.match(/^mcp_([^_]+)_(.+)$/)
|
||||
if (!match) {
|
||||
return `Error: Invalid MCP tool name format: ${fullName}`
|
||||
}
|
||||
|
||||
const [, serverName, toolName] = match
|
||||
const client = this.clients.get(serverName)
|
||||
|
||||
if (!client) {
|
||||
return `Error: MCP server not found: ${serverName}`
|
||||
}
|
||||
|
||||
const result = await client.executeTool(toolName, args)
|
||||
|
||||
// Convert result to string
|
||||
const texts = result.content
|
||||
.filter(c => c.type === "text" && c.text)
|
||||
.map(c => c.text!)
|
||||
|
||||
return texts.join("\n") || (result.isError ? "Tool execution failed" : "Tool executed successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect all configured servers
|
||||
*/
|
||||
async connectAll(): Promise<Record<string, { connected: boolean; error?: string }>> {
|
||||
const results: Record<string, { connected: boolean; error?: string }> = {}
|
||||
|
||||
for (const [name, client] of this.clients) {
|
||||
try {
|
||||
// Add timeout for connection
|
||||
const connectPromise = client.connect()
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Connection timeout")), 15000)
|
||||
)
|
||||
|
||||
await Promise.race([connectPromise, timeoutPromise])
|
||||
results[name] = { connected: true }
|
||||
log.info({ server: name }, "MCP server connected successfully")
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
log.warn({ server: name, error: errorMsg }, "Failed to connect MCP server")
|
||||
results[name] = { connected: false, error: errorMsg }
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all servers
|
||||
*/
|
||||
disconnectAll(): void {
|
||||
for (const client of this.clients.values()) {
|
||||
client.disconnect()
|
||||
}
|
||||
this.clients.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all servers
|
||||
*/
|
||||
getStatus(): Record<string, { connected: boolean }> {
|
||||
const status: Record<string, { connected: boolean }> = {}
|
||||
for (const [name, client] of this.clients) {
|
||||
status[name] = { connected: client.isConnected() }
|
||||
}
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let globalMcpManager: McpManager | null = null
|
||||
|
||||
export function getMcpManager(): McpManager {
|
||||
if (!globalMcpManager) {
|
||||
globalMcpManager = new McpManager()
|
||||
}
|
||||
return globalMcpManager
|
||||
}
|
||||
|
||||
export function resetMcpManager(): void {
|
||||
if (globalMcpManager) {
|
||||
globalMcpManager.disconnectAll()
|
||||
globalMcpManager = null
|
||||
}
|
||||
}
|
||||
15
packages/server/src/mcp/index.ts
Normal file
15
packages/server/src/mcp/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* MCP Module Index
|
||||
* Exports MCP client and manager for external MCP server integration.
|
||||
*/
|
||||
|
||||
export {
|
||||
McpClient,
|
||||
McpManager,
|
||||
getMcpManager,
|
||||
resetMcpManager,
|
||||
type McpServerConfig,
|
||||
type McpToolDefinition,
|
||||
type McpToolCall,
|
||||
type McpToolResult
|
||||
} from "./client"
|
||||
60
packages/server/src/opencode-config.ts
Normal file
60
packages/server/src/opencode-config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createLogger } from "./logger"
|
||||
import { getOpencodeWorkspacesRoot, getUserDataRoot } from "./user-data"
|
||||
|
||||
const log = createLogger({ component: "opencode-config" })
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
|
||||
const prodTemplateDir = path.resolve(__dirname, "opencode-config")
|
||||
|
||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
|
||||
const templateDir = isDevBuild ? devTemplateDir : prodTemplateDir
|
||||
const userConfigDir = path.join(getUserDataRoot(), "opencode-config")
|
||||
const workspaceConfigRoot = getOpencodeWorkspacesRoot()
|
||||
|
||||
export function getOpencodeConfigDir(): string {
|
||||
if (!existsSync(templateDir)) {
|
||||
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
|
||||
}
|
||||
|
||||
if (isDevBuild) {
|
||||
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
|
||||
return templateDir
|
||||
}
|
||||
|
||||
refreshUserConfig()
|
||||
return userConfigDir
|
||||
}
|
||||
|
||||
export function ensureWorkspaceOpencodeConfig(workspaceId: string): string {
|
||||
if (!workspaceId) {
|
||||
return getOpencodeConfigDir()
|
||||
}
|
||||
if (!existsSync(templateDir)) {
|
||||
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
|
||||
}
|
||||
|
||||
const targetDir = path.join(workspaceConfigRoot, workspaceId)
|
||||
if (existsSync(targetDir)) {
|
||||
return targetDir
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||
cpSync(templateDir, targetDir, { recursive: true })
|
||||
return targetDir
|
||||
}
|
||||
|
||||
export function getWorkspaceOpencodeConfigDir(workspaceId: string): string {
|
||||
return path.join(workspaceConfigRoot, workspaceId)
|
||||
}
|
||||
|
||||
function refreshUserConfig() {
|
||||
log.debug({ templateDir, userConfigDir }, "Syncing Opencode config template")
|
||||
rmSync(userConfigDir, { recursive: true, force: true })
|
||||
mkdirSync(path.dirname(userConfigDir), { recursive: true })
|
||||
cpSync(templateDir, userConfigDir, { recursive: true })
|
||||
}
|
||||
141
packages/server/src/releases/release-monitor.ts
Normal file
141
packages/server/src/releases/release-monitor.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { fetch } from "undici"
|
||||
import type { LatestReleaseInfo } from "../api-types"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
interface ReleaseMonitorOptions {
|
||||
currentVersion: string
|
||||
logger: Logger
|
||||
onUpdate: (release: LatestReleaseInfo | null) => void
|
||||
}
|
||||
|
||||
interface GithubReleaseResponse {
|
||||
tag_name?: string
|
||||
name?: string
|
||||
html_url?: string
|
||||
body?: string
|
||||
published_at?: string
|
||||
created_at?: string
|
||||
prerelease?: boolean
|
||||
}
|
||||
|
||||
interface NormalizedVersion {
|
||||
major: number
|
||||
minor: number
|
||||
patch: number
|
||||
prerelease: string | null
|
||||
}
|
||||
|
||||
export interface ReleaseMonitor {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
|
||||
let stopped = false
|
||||
|
||||
const refreshRelease = async () => {
|
||||
if (stopped) return
|
||||
try {
|
||||
const release = await fetchLatestRelease(options)
|
||||
options.onUpdate(release)
|
||||
} catch (error) {
|
||||
options.logger.warn({ err: error }, "Failed to refresh release information")
|
||||
}
|
||||
}
|
||||
|
||||
void refreshRelease()
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||
const response = await fetch(RELEASES_API_URL, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "CodeNomad-CLI",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release API responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as GithubReleaseResponse
|
||||
const tagFromServer = json.tag_name || json.name
|
||||
if (!tagFromServer) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedVersion = stripTagPrefix(tagFromServer)
|
||||
if (!normalizedVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
const current = parseVersion(options.currentVersion)
|
||||
const remote = parseVersion(normalizedVersion)
|
||||
|
||||
if (compareVersions(remote, current) <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
version: normalizedVersion,
|
||||
tag: tagFromServer,
|
||||
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
|
||||
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
|
||||
publishedAt: json.published_at ?? json.created_at,
|
||||
notes: json.body,
|
||||
}
|
||||
}
|
||||
|
||||
function stripTagPrefix(tag: string | undefined): string | null {
|
||||
if (!tag) return null
|
||||
const trimmed = tag.trim()
|
||||
if (!trimmed) return null
|
||||
return trimmed.replace(/^v/i, "")
|
||||
}
|
||||
|
||||
function parseVersion(value: string): NormalizedVersion {
|
||||
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
||||
const [core, prerelease = null] = normalized.split("-", 2)
|
||||
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
||||
const parsed = Number.parseInt(segment, 10)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
})
|
||||
return {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
|
||||
if (a.major !== b.major) {
|
||||
return a.major > b.major ? 1 : -1
|
||||
}
|
||||
if (a.minor !== b.minor) {
|
||||
return a.minor > b.minor ? 1 : -1
|
||||
}
|
||||
if (a.patch !== b.patch) {
|
||||
return a.patch > b.patch ? 1 : -1
|
||||
}
|
||||
|
||||
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
|
||||
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
|
||||
|
||||
if (aPre === bPre) {
|
||||
return 0
|
||||
}
|
||||
if (!aPre) {
|
||||
return 1
|
||||
}
|
||||
if (!bPre) {
|
||||
return -1
|
||||
}
|
||||
return aPre.localeCompare(bPre)
|
||||
}
|
||||
396
packages/server/src/server/http-server.ts
Normal file
396
packages/server/src/server/http-server.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||
import { registerConfigRoutes } from "./routes/config"
|
||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||
import { registerMetaRoutes } from "./routes/meta"
|
||||
import { registerEventRoutes } from "./routes/events"
|
||||
import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerOllamaRoutes } from "./routes/ollama"
|
||||
import { registerQwenRoutes } from "./routes/qwen"
|
||||
import { registerZAIRoutes } from "./routes/zai"
|
||||
import { registerOpenCodeZenRoutes } from "./routes/opencode-zen"
|
||||
import { registerSkillsRoutes } from "./routes/skills"
|
||||
import { registerContextEngineRoutes } from "./routes/context-engine"
|
||||
import { registerNativeSessionsRoutes } from "./routes/native-sessions"
|
||||
import { initSessionManager } from "../storage/session-store"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
port: number
|
||||
workspaceManager: WorkspaceManager
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
fileSystemBrowser: FileSystemBrowser
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
dataDir?: string // For session storage
|
||||
}
|
||||
|
||||
interface HttpServerStartResult {
|
||||
port: number
|
||||
url: string
|
||||
displayHost: string
|
||||
}
|
||||
|
||||
const DEFAULT_HTTP_PORT = 9898
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||
const apiLogger = deps.logger.child({ component: "http" })
|
||||
const sseLogger = deps.logger.child({ component: "sse" })
|
||||
|
||||
// Initialize session manager for Binary-Free Mode
|
||||
const dataDir = deps.dataDir || path.join(process.cwd(), ".codenomad-data")
|
||||
initSessionManager(dataDir)
|
||||
|
||||
const sseClients = new Set<() => void>()
|
||||
const registerSseClient = (cleanup: () => void) => {
|
||||
sseClients.add(cleanup)
|
||||
return () => sseClients.delete(cleanup)
|
||||
}
|
||||
const closeSseClients = () => {
|
||||
for (const cleanup of Array.from(sseClients)) {
|
||||
cleanup()
|
||||
}
|
||||
sseClients.clear()
|
||||
}
|
||||
|
||||
app.addHook("onRequest", (request, _reply, done) => {
|
||||
; (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
|
||||
start: process.hrtime.bigint(),
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
app.addHook("onResponse", (request, reply, done) => {
|
||||
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
|
||||
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
|
||||
const base = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
status: reply.statusCode,
|
||||
durationMs,
|
||||
}
|
||||
apiLogger.debug(base, "HTTP request completed")
|
||||
if (apiLogger.isLevelEnabled("trace")) {
|
||||
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
app.register(replyFrom, {
|
||||
contentTypesToEncode: [],
|
||||
undici: {
|
||||
connections: 16,
|
||||
pipelining: 1,
|
||||
bodyTimeout: 0,
|
||||
headersTimeout: 0,
|
||||
},
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
instanceStore: deps.instanceStore,
|
||||
configStore: deps.configStore,
|
||||
})
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerOllamaRoutes(app, { logger: deps.logger })
|
||||
registerQwenRoutes(app, { logger: deps.logger })
|
||||
registerZAIRoutes(app, { logger: deps.logger })
|
||||
registerOpenCodeZenRoutes(app, { logger: deps.logger })
|
||||
registerSkillsRoutes(app)
|
||||
registerContextEngineRoutes(app)
|
||||
|
||||
// Register Binary-Free Mode native sessions routes
|
||||
registerNativeSessionsRoutes(app, {
|
||||
logger: deps.logger,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
dataDir,
|
||||
eventBus: deps.eventBus,
|
||||
})
|
||||
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
|
||||
if (deps.uiDevServerUrl) {
|
||||
setupDevProxy(app, deps.uiDevServerUrl)
|
||||
} else {
|
||||
setupStaticUi(app, deps.uiStaticDir)
|
||||
}
|
||||
|
||||
return {
|
||||
instance: app,
|
||||
start: async (): Promise<HttpServerStartResult> => {
|
||||
const attemptListen = async (requestedPort: number) => {
|
||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
|
||||
return { addressInfo, requestedPort }
|
||||
}
|
||||
|
||||
const autoPortRequested = deps.port === 0
|
||||
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
|
||||
|
||||
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||
if (!autoPortRequested) return false
|
||||
const err = error as NodeJS.ErrnoException | undefined
|
||||
return Boolean(err && err.code === "EADDRINUSE")
|
||||
}
|
||||
|
||||
let listenResult
|
||||
|
||||
try {
|
||||
listenResult = await attemptListen(primaryPort)
|
||||
} catch (error) {
|
||||
if (!shouldRetryWithEphemeral(error)) {
|
||||
throw error
|
||||
}
|
||||
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
|
||||
listenResult = await attemptListen(0)
|
||||
}
|
||||
|
||||
let actualPort = listenResult.requestedPort
|
||||
|
||||
if (typeof listenResult.addressInfo === "string") {
|
||||
try {
|
||||
const parsed = new URL(listenResult.addressInfo)
|
||||
actualPort = Number(parsed.port) || listenResult.requestedPort
|
||||
} catch {
|
||||
actualPort = listenResult.requestedPort
|
||||
}
|
||||
} else {
|
||||
const address = app.server.address()
|
||||
if (typeof address === "object" && address) {
|
||||
actualPort = address.port
|
||||
}
|
||||
}
|
||||
|
||||
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.serverMeta.host = deps.host
|
||||
deps.serverMeta.port = actualPort
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
|
||||
return { port: actualPort, url: serverUrl, displayHost }
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface InstanceProxyDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||
app.register(async (instance) => {
|
||||
instance.removeAllContentTypeParsers()
|
||||
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||
|
||||
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
||||
})
|
||||
}
|
||||
|
||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||
|
||||
async function proxyWorkspaceRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const { request, reply, workspaceManager, logger } = args
|
||||
const workspaceId = (request.params as { id: string }).id
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const port = workspaceManager.getInstancePort(workspaceId)
|
||||
if (!port) {
|
||||
reply.code(502).send({ error: "Workspace instance is not ready" })
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
|
||||
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
||||
}
|
||||
|
||||
return reply.from(targetUrl, {
|
||||
onError: (proxyReply, { error }) => {
|
||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||
if (!proxyReply.sent) {
|
||||
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
if (!pathSuffix || pathSuffix === "/") {
|
||||
return "/"
|
||||
}
|
||||
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(uiDir)) {
|
||||
app.log.warn({ uiDir }, "UI static directory missing; API endpoints only")
|
||||
return
|
||||
}
|
||||
|
||||
app.register(fastifyStatic, {
|
||||
root: uiDir,
|
||||
prefix: "/",
|
||||
decorateReply: false,
|
||||
})
|
||||
|
||||
const indexPath = path.join(uiDir, "index.html")
|
||||
|
||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.raw.url ?? ""
|
||||
if (isApiRequest(url)) {
|
||||
reply.code(404).send({ message: "Not Found" })
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(indexPath)) {
|
||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||
} else {
|
||||
reply.code(404).send({ message: "UI bundle missing" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
||||
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = request.raw.url ?? ""
|
||||
if (isApiRequest(url)) {
|
||||
reply.code(404).send({ message: "Not Found" })
|
||||
return
|
||||
}
|
||||
void proxyToDevServer(request, reply, upstreamBase)
|
||||
})
|
||||
}
|
||||
|
||||
async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) {
|
||||
try {
|
||||
const targetUrl = new URL(request.raw.url ?? "/", upstreamBase)
|
||||
const response = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers: buildProxyHeaders(request.headers),
|
||||
})
|
||||
|
||||
response.headers.forEach((value, key) => {
|
||||
reply.header(key, value)
|
||||
})
|
||||
|
||||
reply.code(response.status)
|
||||
|
||||
if (!response.body || request.method === "HEAD") {
|
||||
reply.send()
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
reply.send(buffer)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to proxy UI request to dev server")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send("UI dev server is unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isApiRequest(rawUrl: string | null | undefined) {
|
||||
if (!rawUrl) return false
|
||||
const pathname = rawUrl.split("?")[0] ?? ""
|
||||
return pathname === "/api" || pathname.startsWith("/api/")
|
||||
}
|
||||
|
||||
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||
if (!value || key.toLowerCase() === "host") continue
|
||||
result[key] = Array.isArray(value) ? value.join(",") : value
|
||||
}
|
||||
return result
|
||||
}
|
||||
62
packages/server/src/server/routes/config.ts
Normal file
62
packages/server/src/server/routes/config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { ConfigStore } from "../../config/store"
|
||||
import { BinaryRegistry } from "../../config/binaries"
|
||||
import { ConfigFileSchema } from "../../config/schema"
|
||||
|
||||
interface RouteDeps {
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
}
|
||||
|
||||
const BinaryCreateSchema = z.object({
|
||||
path: z.string(),
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryUpdateSchema = z.object({
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryValidateSchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/config/app", async () => deps.configStore.get())
|
||||
|
||||
app.put("/api/config/app", async (request) => {
|
||||
const body = ConfigFileSchema.parse(request.body ?? {})
|
||||
deps.configStore.replace(body)
|
||||
return deps.configStore.get()
|
||||
})
|
||||
|
||||
app.get("/api/config/binaries", async () => {
|
||||
return { binaries: deps.binaryRegistry.list() }
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries", async (request, reply) => {
|
||||
const body = BinaryCreateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.create(body)
|
||||
reply.code(201)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
|
||||
const body = BinaryUpdateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.update(request.params.id, body)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
|
||||
deps.binaryRegistry.remove(request.params.id)
|
||||
reply.code(204)
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries/validate", async (request) => {
|
||||
const body = BinaryValidateSchema.parse(request.body ?? {})
|
||||
return deps.binaryRegistry.validatePath(body.path)
|
||||
})
|
||||
}
|
||||
130
packages/server/src/server/routes/context-engine.ts
Normal file
130
packages/server/src/server/routes/context-engine.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Context-Engine API routes
|
||||
* Provides endpoints for querying the Context-Engine status and manually triggering operations.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { getContextEngineService } from "../../context-engine"
|
||||
|
||||
export function registerContextEngineRoutes(app: FastifyInstance) {
|
||||
// Get Context-Engine status
|
||||
app.get("/api/context-engine/status", async (request, reply) => {
|
||||
const service = getContextEngineService()
|
||||
|
||||
if (!service) {
|
||||
return reply.send({
|
||||
status: "stopped",
|
||||
message: "Context-Engine service not initialized"
|
||||
})
|
||||
}
|
||||
|
||||
const status = service.getStatus()
|
||||
const client = service.getClient()
|
||||
|
||||
// Get more detailed status from the engine if it's running
|
||||
let details: Record<string, unknown> = {}
|
||||
if (service.isReady()) {
|
||||
try {
|
||||
const engineStatus = await client.getStatus()
|
||||
details = {
|
||||
indexing: engineStatus.indexing,
|
||||
indexed_files: engineStatus.indexed_files,
|
||||
last_indexed: engineStatus.last_indexed
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, just don't include details
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
status,
|
||||
ready: service.isReady(),
|
||||
...details
|
||||
})
|
||||
})
|
||||
|
||||
// Get Context-Engine health
|
||||
app.get("/api/context-engine/health", async (request, reply) => {
|
||||
const service = getContextEngineService()
|
||||
|
||||
if (!service) {
|
||||
return reply.send({ status: "unhealthy", reason: "Service not initialized" })
|
||||
}
|
||||
|
||||
const client = service.getClient()
|
||||
const health = await client.health()
|
||||
|
||||
return reply.send(health)
|
||||
})
|
||||
|
||||
// Manually trigger indexing for a path
|
||||
app.post("/api/context-engine/index", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: { type: "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const service = getContextEngineService()
|
||||
|
||||
if (!service) {
|
||||
return reply.status(503).send({
|
||||
error: "Context-Engine service not available"
|
||||
})
|
||||
}
|
||||
|
||||
const { path } = request.body as { path: string }
|
||||
|
||||
// Start indexing (non-blocking)
|
||||
service.indexPath(path).catch(() => {
|
||||
// Errors are logged internally
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
status: "started",
|
||||
message: `Indexing started for: ${path}`
|
||||
})
|
||||
})
|
||||
|
||||
// Query the Context-Engine
|
||||
app.post("/api/context-engine/query", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["query"],
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
context_window: { type: "number" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const service = getContextEngineService()
|
||||
|
||||
if (!service || !service.isReady()) {
|
||||
return reply.status(503).send({
|
||||
error: "Context-Engine not ready",
|
||||
results: [],
|
||||
total_results: 0
|
||||
})
|
||||
}
|
||||
|
||||
const { query, context_window } = request.body as { query: string; context_window?: number }
|
||||
const client = service.getClient()
|
||||
|
||||
try {
|
||||
const response = await client.query(query, context_window ?? 4096)
|
||||
return reply.send(response)
|
||||
} catch (error) {
|
||||
return reply.status(500).send({
|
||||
error: error instanceof Error ? error.message : "Query failed",
|
||||
results: [],
|
||||
total_results: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
61
packages/server/src/server/routes/events.ts
Normal file
61
packages/server/src/server/routes/events.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
import { Logger } from "../../logger"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
registerClient: (cleanup: () => void) => () => void
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
let nextClientId = 0
|
||||
|
||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const clientId = ++nextClientId
|
||||
deps.logger.debug({ clientId }, "SSE client connected")
|
||||
|
||||
const origin = request.headers.origin ?? "*"
|
||||
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
|
||||
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||
reply.raw.setHeader("Connection", "keep-alive")
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const send = (event: WorkspaceEventPayload) => {
|
||||
deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched")
|
||||
if (deps.logger.isLevelEnabled("trace")) {
|
||||
deps.logger.trace({ clientId, event }, "SSE event payload")
|
||||
}
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
|
||||
const unsubscribe = deps.eventBus.onEvent(send)
|
||||
const heartbeat = setInterval(() => {
|
||||
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
||||
}, 15000)
|
||||
|
||||
let closed = false
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
clearInterval(heartbeat)
|
||||
unsubscribe()
|
||||
reply.raw.end?.()
|
||||
deps.logger.debug({ clientId }, "SSE client disconnected")
|
||||
}
|
||||
|
||||
const unregister = deps.registerClient(close)
|
||||
|
||||
const handleClose = () => {
|
||||
close()
|
||||
unregister()
|
||||
}
|
||||
|
||||
request.raw.on("close", handleClose)
|
||||
request.raw.on("error", handleClose)
|
||||
})
|
||||
}
|
||||
27
packages/server/src/server/routes/filesystem.ts
Normal file
27
packages/server/src/server/routes/filesystem.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { FileSystemBrowser } from "../../filesystem/browser"
|
||||
|
||||
interface RouteDeps {
|
||||
fileSystemBrowser: FileSystemBrowser
|
||||
}
|
||||
|
||||
const FilesystemQuerySchema = z.object({
|
||||
path: z.string().optional(),
|
||||
includeFiles: z.coerce.boolean().optional(),
|
||||
})
|
||||
|
||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/filesystem", async (request, reply) => {
|
||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||
|
||||
try {
|
||||
return deps.fileSystemBrowser.browse(query.path, {
|
||||
includeFiles: query.includeFiles,
|
||||
})
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: (error as Error).message }
|
||||
}
|
||||
})
|
||||
}
|
||||
157
packages/server/src/server/routes/meta.ts
Normal file
157
packages/server/src/server/routes/meta.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import os from "os"
|
||||
import { existsSync } from "fs"
|
||||
import { NetworkAddress, ServerMeta, PortAvailabilityResponse } from "../../api-types"
|
||||
import { getAvailablePort } from "../../utils/port"
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
}
|
||||
|
||||
export interface ModeInfo {
|
||||
mode: "lite" | "full"
|
||||
binaryFreeMode: boolean
|
||||
nativeSessions: boolean
|
||||
opencodeBinaryAvailable: boolean
|
||||
providers: {
|
||||
qwen: boolean
|
||||
zai: boolean
|
||||
zen: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
|
||||
|
||||
// Mode detection endpoint for Binary-Free Mode
|
||||
app.get("/api/meta/mode", async (): Promise<ModeInfo> => {
|
||||
// Check if any OpenCode binary is available
|
||||
const opencodePaths = [
|
||||
process.env.OPENCODE_PATH,
|
||||
"opencode",
|
||||
"opencode.exe",
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
let binaryAvailable = false
|
||||
for (const p of opencodePaths) {
|
||||
if (existsSync(p)) {
|
||||
binaryAvailable = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// In Binary-Free Mode, we use native session management
|
||||
const binaryFreeMode = !binaryAvailable
|
||||
|
||||
return {
|
||||
mode: binaryFreeMode ? "lite" : "full",
|
||||
binaryFreeMode,
|
||||
nativeSessions: true, // Native sessions are always available
|
||||
opencodeBinaryAvailable: binaryAvailable,
|
||||
providers: {
|
||||
qwen: true, // Always available
|
||||
zai: true, // Always available
|
||||
zen: true, // Always available (needs API key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/api/ports/available", async () => {
|
||||
const port = await getAvailablePort(3000)
|
||||
const response: PortAvailabilityResponse = { port }
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const port = resolvePort(meta)
|
||||
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
|
||||
|
||||
return {
|
||||
...meta,
|
||||
port,
|
||||
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePort(meta: ServerMeta): number {
|
||||
if (Number.isInteger(meta.port) && meta.port > 0) {
|
||||
return meta.port
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(meta.httpBaseUrl)
|
||||
const port = Number(parsed.port)
|
||||
return Number.isInteger(port) && port > 0 ? port : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const results: NetworkAddress[] = []
|
||||
|
||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||
if (!ip || ip === "0.0.0.0") return
|
||||
const key = `ipv4-${ip}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
|
||||
}
|
||||
|
||||
const normalizeFamily = (value: string | number) => {
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === "ipv4") {
|
||||
return "ipv4" as const
|
||||
}
|
||||
}
|
||||
if (value === 4) return "ipv4" as const
|
||||
return null
|
||||
}
|
||||
|
||||
if (host === "0.0.0.0") {
|
||||
// Enumerate system interfaces (IPv4 only)
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
const family = normalizeFamily(entry.family)
|
||||
if (!family) continue
|
||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||
addAddress(entry.address, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include loopback address
|
||||
addAddress("127.0.0.1", "loopback")
|
||||
|
||||
// Include explicitly configured host if it was IPv4
|
||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||
const isLoopback = host.startsWith("127.")
|
||||
addAddress(host, isLoopback ? "loopback" : "external")
|
||||
}
|
||||
|
||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
})
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
629
packages/server/src/server/routes/native-sessions.ts
Normal file
629
packages/server/src/server/routes/native-sessions.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* Native Sessions API Routes - Binary-Free Mode
|
||||
*
|
||||
* These routes provide session management without requiring the OpenCode binary.
|
||||
* They're used when running in "Lite Mode" or when OpenCode is unavailable.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { Logger } from "../../logger"
|
||||
import { getSessionManager, Session, SessionMessage } from "../../storage/session-store"
|
||||
import { CORE_TOOLS, executeTools, type ToolCall, type ToolResult } from "../../tools/executor"
|
||||
import { getMcpManager } from "../../mcp/client"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
import { OpenCodeZenClient, ChatMessage } from "../../integrations/opencode-zen"
|
||||
import { EventBus } from "../../events/bus"
|
||||
|
||||
interface NativeSessionsDeps {
|
||||
logger: Logger
|
||||
workspaceManager: WorkspaceManager
|
||||
dataDir: string
|
||||
eventBus?: EventBus
|
||||
}
|
||||
|
||||
// Maximum tool execution loops to prevent infinite loops
|
||||
const MAX_TOOL_LOOPS = 10
|
||||
|
||||
export function registerNativeSessionsRoutes(app: FastifyInstance, deps: NativeSessionsDeps) {
|
||||
const logger = deps.logger.child({ component: "native-sessions" })
|
||||
const sessionManager = getSessionManager(deps.dataDir)
|
||||
|
||||
// List all sessions for a workspace
|
||||
app.get<{ Params: { workspaceId: string } }>("/api/native/workspaces/:workspaceId/sessions", async (request, reply) => {
|
||||
try {
|
||||
const sessions = await sessionManager.listSessions(request.params.workspaceId)
|
||||
return { sessions }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to list sessions")
|
||||
reply.code(500)
|
||||
return { error: "Failed to list sessions" }
|
||||
}
|
||||
})
|
||||
|
||||
// Create a new session
|
||||
app.post<{
|
||||
Params: { workspaceId: string }
|
||||
Body: { title?: string; parentId?: string; model?: { providerId: string; modelId: string }; agent?: string }
|
||||
}>("/api/native/workspaces/:workspaceId/sessions", async (request, reply) => {
|
||||
try {
|
||||
const session = await sessionManager.createSession(request.params.workspaceId, request.body)
|
||||
|
||||
// Emit session created event (using any for custom event type)
|
||||
if (deps.eventBus) {
|
||||
deps.eventBus.publish({
|
||||
type: "native.session.created",
|
||||
workspaceId: request.params.workspaceId,
|
||||
session
|
||||
} as any)
|
||||
}
|
||||
|
||||
reply.code(201)
|
||||
return { session }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to create session")
|
||||
reply.code(500)
|
||||
return { error: "Failed to create session" }
|
||||
}
|
||||
})
|
||||
|
||||
// Get a specific session
|
||||
app.get<{ Params: { workspaceId: string; sessionId: string } }>("/api/native/workspaces/:workspaceId/sessions/:sessionId", async (request, reply) => {
|
||||
try {
|
||||
const session = await sessionManager.getSession(request.params.workspaceId, request.params.sessionId)
|
||||
if (!session) {
|
||||
reply.code(404)
|
||||
return { error: "Session not found" }
|
||||
}
|
||||
return { session }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get session")
|
||||
reply.code(500)
|
||||
return { error: "Failed to get session" }
|
||||
}
|
||||
})
|
||||
|
||||
// Update a session
|
||||
app.patch<{
|
||||
Params: { workspaceId: string; sessionId: string }
|
||||
Body: Partial<Session>
|
||||
}>("/api/native/workspaces/:workspaceId/sessions/:sessionId", async (request, reply) => {
|
||||
try {
|
||||
const session = await sessionManager.updateSession(
|
||||
request.params.workspaceId,
|
||||
request.params.sessionId,
|
||||
request.body
|
||||
)
|
||||
if (!session) {
|
||||
reply.code(404)
|
||||
return { error: "Session not found" }
|
||||
}
|
||||
return { session }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to update session")
|
||||
reply.code(500)
|
||||
return { error: "Failed to update session" }
|
||||
}
|
||||
})
|
||||
|
||||
// Delete a session
|
||||
app.delete<{ Params: { workspaceId: string; sessionId: string } }>("/api/native/workspaces/:workspaceId/sessions/:sessionId", async (request, reply) => {
|
||||
try {
|
||||
const deleted = await sessionManager.deleteSession(request.params.workspaceId, request.params.sessionId)
|
||||
if (!deleted) {
|
||||
reply.code(404)
|
||||
return { error: "Session not found" }
|
||||
}
|
||||
reply.code(204)
|
||||
return
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to delete session")
|
||||
reply.code(500)
|
||||
return { error: "Failed to delete session" }
|
||||
}
|
||||
})
|
||||
|
||||
// Get messages for a session
|
||||
app.get<{ Params: { workspaceId: string; sessionId: string } }>("/api/native/workspaces/:workspaceId/sessions/:sessionId/messages", async (request, reply) => {
|
||||
try {
|
||||
const messages = await sessionManager.getSessionMessages(
|
||||
request.params.workspaceId,
|
||||
request.params.sessionId
|
||||
)
|
||||
return { messages }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get messages")
|
||||
reply.code(500)
|
||||
return { error: "Failed to get messages" }
|
||||
}
|
||||
})
|
||||
|
||||
// Add a message (user prompt) and get streaming response
|
||||
app.post<{
|
||||
Params: { workspaceId: string; sessionId: string }
|
||||
Body: {
|
||||
content: string
|
||||
provider: "qwen" | "zai" | "zen"
|
||||
model?: string
|
||||
accessToken?: string
|
||||
resourceUrl?: string
|
||||
enableTools?: boolean
|
||||
systemPrompt?: string
|
||||
}
|
||||
}>("/api/native/workspaces/:workspaceId/sessions/:sessionId/prompt", async (request, reply) => {
|
||||
const { workspaceId, sessionId } = request.params
|
||||
const { content, provider, model, accessToken, resourceUrl, enableTools = true, systemPrompt } = request.body
|
||||
|
||||
try {
|
||||
// Add user message
|
||||
const userMessage = await sessionManager.addMessage(workspaceId, sessionId, {
|
||||
role: "user",
|
||||
content,
|
||||
status: "completed",
|
||||
})
|
||||
|
||||
// Get workspace path
|
||||
const workspace = deps.workspaceManager.get(workspaceId)
|
||||
const workspacePath = workspace?.path ?? process.cwd()
|
||||
|
||||
// Get all messages for context
|
||||
const allMessages = await sessionManager.getSessionMessages(workspaceId, sessionId)
|
||||
|
||||
// Build chat messages array
|
||||
const chatMessages: ChatMessage[] = []
|
||||
|
||||
// Add system prompt if provided
|
||||
if (systemPrompt) {
|
||||
chatMessages.push({ role: "system", content: systemPrompt })
|
||||
}
|
||||
|
||||
// Add conversation history
|
||||
for (const m of allMessages) {
|
||||
if (m.role === "user" || m.role === "assistant" || m.role === "system") {
|
||||
chatMessages.push({ role: m.role, content: m.content ?? "" })
|
||||
}
|
||||
}
|
||||
|
||||
// Load MCP tools
|
||||
let allTools = [...CORE_TOOLS]
|
||||
if (enableTools) {
|
||||
try {
|
||||
const mcpManager = getMcpManager()
|
||||
await mcpManager.loadConfig(workspacePath)
|
||||
const mcpTools = await mcpManager.getToolsAsOpenAIFormat()
|
||||
allTools = [...CORE_TOOLS, ...mcpTools]
|
||||
} catch (mcpError) {
|
||||
logger.warn({ error: mcpError }, "Failed to load MCP tools")
|
||||
}
|
||||
}
|
||||
|
||||
// Create streaming response
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
})
|
||||
|
||||
// Create assistant message placeholder
|
||||
const assistantMessage = await sessionManager.addMessage(workspaceId, sessionId, {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
})
|
||||
|
||||
let fullContent = ""
|
||||
|
||||
try {
|
||||
// Route to the appropriate provider
|
||||
fullContent = await streamWithProvider({
|
||||
provider,
|
||||
model,
|
||||
accessToken,
|
||||
resourceUrl,
|
||||
messages: chatMessages,
|
||||
tools: enableTools ? allTools : [],
|
||||
workspacePath,
|
||||
rawResponse: reply.raw,
|
||||
logger,
|
||||
})
|
||||
} catch (streamError) {
|
||||
logger.error({ error: streamError }, "Stream error")
|
||||
reply.raw.write(`data: ${JSON.stringify({ error: String(streamError) })}\n\n`)
|
||||
}
|
||||
|
||||
// Update assistant message with full content
|
||||
await sessionManager.updateMessage(workspaceId, assistantMessage.id, {
|
||||
content: fullContent,
|
||||
status: "completed",
|
||||
})
|
||||
|
||||
// Emit message event (using any for custom event type)
|
||||
if (deps.eventBus) {
|
||||
deps.eventBus.publish({
|
||||
type: "native.message.completed",
|
||||
workspaceId,
|
||||
sessionId,
|
||||
messageId: assistantMessage.id,
|
||||
} as any)
|
||||
}
|
||||
|
||||
reply.raw.write('data: [DONE]\n\n')
|
||||
reply.raw.end()
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to process prompt")
|
||||
if (!reply.sent) {
|
||||
reply.code(500)
|
||||
return { error: "Failed to process prompt" }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// SSE endpoint for session events
|
||||
app.get<{ Params: { workspaceId: string } }>("/api/native/workspaces/:workspaceId/events", async (request, reply) => {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
})
|
||||
|
||||
// Send initial ping
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: "ping" })}\n\n`)
|
||||
|
||||
// Keep connection alive
|
||||
const keepAlive = setInterval(() => {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: "ping" })}\n\n`)
|
||||
}, 30000)
|
||||
|
||||
// Handle client disconnect
|
||||
request.raw.on("close", () => {
|
||||
clearInterval(keepAlive)
|
||||
})
|
||||
})
|
||||
|
||||
logger.info("Native sessions routes registered (Binary-Free Mode)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream chat with the appropriate provider
|
||||
*/
|
||||
async function streamWithProvider(opts: {
|
||||
provider: "qwen" | "zai" | "zen"
|
||||
model?: string
|
||||
accessToken?: string
|
||||
resourceUrl?: string
|
||||
messages: ChatMessage[]
|
||||
tools: any[]
|
||||
workspacePath: string
|
||||
rawResponse: any
|
||||
logger: Logger
|
||||
}): Promise<string> {
|
||||
const { provider, model, accessToken, resourceUrl, messages, tools, workspacePath, rawResponse, logger } = opts
|
||||
|
||||
let fullContent = ""
|
||||
let loopCount = 0
|
||||
let currentMessages = [...messages]
|
||||
|
||||
// Tool execution loop
|
||||
while (loopCount < MAX_TOOL_LOOPS) {
|
||||
loopCount++
|
||||
|
||||
let responseContent = ""
|
||||
let toolCalls: ToolCall[] = []
|
||||
|
||||
// Route to the appropriate provider
|
||||
switch (provider) {
|
||||
case "zen":
|
||||
const zenResult = await streamWithZen(model, currentMessages, tools, rawResponse, logger)
|
||||
responseContent = zenResult.content
|
||||
toolCalls = zenResult.toolCalls
|
||||
break
|
||||
|
||||
case "qwen":
|
||||
const qwenResult = await streamWithQwen(accessToken, resourceUrl, model, currentMessages, tools, rawResponse, logger)
|
||||
responseContent = qwenResult.content
|
||||
toolCalls = qwenResult.toolCalls
|
||||
break
|
||||
|
||||
case "zai":
|
||||
const zaiResult = await streamWithZAI(accessToken, model, currentMessages, tools, rawResponse, logger)
|
||||
responseContent = zaiResult.content
|
||||
toolCalls = zaiResult.toolCalls
|
||||
break
|
||||
}
|
||||
|
||||
fullContent += responseContent
|
||||
|
||||
// If no tool calls, we're done
|
||||
if (toolCalls.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
// Execute tools
|
||||
logger.info({ toolCount: toolCalls.length }, "Executing tool calls")
|
||||
|
||||
// Add assistant message with tool calls
|
||||
currentMessages.push({
|
||||
role: "assistant",
|
||||
content: responseContent,
|
||||
tool_calls: toolCalls.map(tc => ({
|
||||
id: tc.id,
|
||||
type: "function" as const,
|
||||
function: tc.function
|
||||
}))
|
||||
})
|
||||
|
||||
// Execute each tool and add result
|
||||
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
const tc = toolCalls[i]
|
||||
const result = toolResults[i]
|
||||
|
||||
// Emit tool execution event
|
||||
rawResponse.write(`data: ${JSON.stringify({
|
||||
type: "tool_execution",
|
||||
tool: tc.function.name,
|
||||
result: result?.content?.substring(0, 200) // Preview
|
||||
})}\n\n`)
|
||||
|
||||
currentMessages.push({
|
||||
role: "tool",
|
||||
content: result?.content ?? "Tool execution failed",
|
||||
tool_call_id: tc.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream with OpenCode Zen (free models)
|
||||
*/
|
||||
async function streamWithZen(
|
||||
model: string | undefined,
|
||||
messages: ChatMessage[],
|
||||
tools: any[],
|
||||
rawResponse: any,
|
||||
logger: Logger
|
||||
): Promise<{ content: string; toolCalls: ToolCall[] }> {
|
||||
const zenClient = new OpenCodeZenClient()
|
||||
let content = ""
|
||||
const toolCalls: ToolCall[] = []
|
||||
|
||||
try {
|
||||
const stream = zenClient.chatStream({
|
||||
model: model ?? "gpt-5-nano",
|
||||
messages,
|
||||
stream: true,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
tool_choice: tools.length > 0 ? "auto" : undefined,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta?.content) {
|
||||
content += delta.content
|
||||
rawResponse.write(`data: ${JSON.stringify({ choices: [{ delta: { content: delta.content } }] })}\n\n`)
|
||||
}
|
||||
|
||||
// Handle tool calls (if model supports them)
|
||||
const deltaToolCalls = (delta as any)?.tool_calls
|
||||
if (deltaToolCalls) {
|
||||
for (const tc of deltaToolCalls) {
|
||||
if (tc.function?.name) {
|
||||
toolCalls.push({
|
||||
id: tc.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments ?? "{}"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Zen streaming error")
|
||||
throw error
|
||||
}
|
||||
|
||||
return { content, toolCalls }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream with Qwen API
|
||||
*/
|
||||
async function streamWithQwen(
|
||||
accessToken: string | undefined,
|
||||
resourceUrl: string | undefined,
|
||||
model: string | undefined,
|
||||
messages: ChatMessage[],
|
||||
tools: any[],
|
||||
rawResponse: any,
|
||||
logger: Logger
|
||||
): Promise<{ content: string; toolCalls: ToolCall[] }> {
|
||||
if (!accessToken) {
|
||||
throw new Error("Qwen access token required. Please authenticate with Qwen first.")
|
||||
}
|
||||
|
||||
const baseUrl = resourceUrl ?? "https://chat.qwen.ai"
|
||||
let content = ""
|
||||
const toolCalls: ToolCall[] = []
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model ?? "qwen-plus-latest",
|
||||
messages,
|
||||
stream: true,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
tool_choice: tools.length > 0 ? "auto" : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`Qwen API error: ${response.status} - ${error}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error("No response body")
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6)
|
||||
if (data === "[DONE]") continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const delta = parsed.choices?.[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
content += delta.content
|
||||
rawResponse.write(`data: ${JSON.stringify({ choices: [{ delta: { content: delta.content } }] })}\n\n`)
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
if (tc.function?.name) {
|
||||
toolCalls.push({
|
||||
id: tc.id ?? `call_${Date.now()}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments ?? "{}"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Qwen streaming error")
|
||||
throw error
|
||||
}
|
||||
|
||||
return { content, toolCalls }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream with Z.AI API
|
||||
*/
|
||||
async function streamWithZAI(
|
||||
accessToken: string | undefined,
|
||||
model: string | undefined,
|
||||
messages: ChatMessage[],
|
||||
tools: any[],
|
||||
rawResponse: any,
|
||||
logger: Logger
|
||||
): Promise<{ content: string; toolCalls: ToolCall[] }> {
|
||||
let content = ""
|
||||
const toolCalls: ToolCall[] = []
|
||||
|
||||
const baseUrl = "https://api.z.ai"
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
headers["Authorization"] = `Bearer ${accessToken}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: model ?? "z1-mini",
|
||||
messages,
|
||||
stream: true,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
tool_choice: tools.length > 0 ? "auto" : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`Z.AI API error: ${response.status} - ${error}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error("No response body")
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6)
|
||||
if (data === "[DONE]") continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const delta = parsed.choices?.[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
content += delta.content
|
||||
rawResponse.write(`data: ${JSON.stringify({ choices: [{ delta: { content: delta.content } }] })}\n\n`)
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
if (tc.function?.name) {
|
||||
toolCalls.push({
|
||||
id: tc.id ?? `call_${Date.now()}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments ?? "{}"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Z.AI streaming error")
|
||||
throw error
|
||||
}
|
||||
|
||||
return { content, toolCalls }
|
||||
}
|
||||
591
packages/server/src/server/routes/ollama.ts
Normal file
591
packages/server/src/server/routes/ollama.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
import { FastifyInstance, FastifyReply } from "fastify"
|
||||
import {
|
||||
OllamaCloudClient,
|
||||
type OllamaCloudConfig,
|
||||
type ChatRequest,
|
||||
type EmbeddingRequest,
|
||||
type ToolDefinition
|
||||
} from "../../integrations/ollama-cloud"
|
||||
import { Logger } from "../../logger"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { getUserIntegrationsDir } from "../../user-data"
|
||||
|
||||
const CONFIG_DIR = getUserIntegrationsDir()
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, "ollama-config.json")
|
||||
|
||||
interface OllamaRouteDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export async function registerOllamaRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: OllamaRouteDeps
|
||||
) {
|
||||
const logger = deps.logger.child({ component: "ollama-routes" })
|
||||
|
||||
app.get('/api/ollama/config', async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
return { config: { ...config, apiKey: config.apiKey ? '***' : undefined } }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get Ollama config")
|
||||
return reply.status(500).send({ error: "Failed to get Ollama configuration" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/ollama/config', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['enabled'],
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
apiKey: { type: 'string' },
|
||||
endpoint: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { enabled, apiKey, endpoint } = request.body as any
|
||||
updateOllamaConfig({ enabled, apiKey, endpoint })
|
||||
logger.info("Ollama Cloud configuration updated")
|
||||
return { success: true, config: { enabled, endpoint, apiKey: apiKey ? '***' : undefined } }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to update Ollama config")
|
||||
return reply.status(500).send({ error: "Failed to update Ollama configuration" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/ollama/test', 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 isConnected = await client.testConnection()
|
||||
|
||||
return { connected: isConnected }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Ollama Cloud connection test failed")
|
||||
return reply.status(500).send({ error: "Connection test failed" })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/ollama/models', async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.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 models = await client.listModels()
|
||||
|
||||
logger.info({ modelCount: models.length }, "Ollama models fetched successfully")
|
||||
return { models }
|
||||
} catch (error: any) {
|
||||
logger.error({ error: error?.message || error }, "Failed to list Ollama models")
|
||||
return reply.status(500).send({ error: error?.message || "Failed to list models" })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/ollama/models/cloud', 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 cloudModels = await client.getCloudModels()
|
||||
|
||||
return { models: cloudModels }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to list cloud models")
|
||||
return reply.status(500).send({ error: "Failed to list cloud models" })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/ollama/models/thinking', async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||
}
|
||||
|
||||
const client = new OllamaCloudClient(config)
|
||||
const thinkingModels = await client.getThinkingCapableModels()
|
||||
|
||||
return { models: thinkingModels }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to list thinking models")
|
||||
return reply.status(500).send({ error: "Failed to list thinking models" })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/ollama/models/vision', async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||
}
|
||||
|
||||
const client = new OllamaCloudClient(config)
|
||||
const visionModels = await client.getVisionCapableModels()
|
||||
|
||||
return { models: visionModels }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to list vision models")
|
||||
return reply.status(500).send({ error: "Failed to list vision models" })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/ollama/models/embedding', async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||
}
|
||||
|
||||
const client = new OllamaCloudClient(config)
|
||||
const embeddingModels = await client.getEmbeddingModels()
|
||||
|
||||
return { models: embeddingModels }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to list embedding models")
|
||||
return reply.status(500).send({ error: "Failed to list embedding models" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/ollama/chat', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['model', 'messages'],
|
||||
properties: {
|
||||
model: { type: 'string' },
|
||||
messages: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['role', 'content'],
|
||||
properties: {
|
||||
role: { type: 'string', enum: ['user', 'assistant', 'system'] },
|
||||
content: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
stream: { type: 'boolean' },
|
||||
think: { type: ['boolean', 'string'] },
|
||||
format: { type: ['string', 'object'] },
|
||||
tools: { type: 'array' },
|
||||
web_search: { type: 'boolean' },
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
temperature: { type: 'number', minimum: 0, maximum: 2 },
|
||||
top_p: { type: 'number', minimum: 0, maximum: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 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.chat(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: any) {
|
||||
logger.error({ error: streamError?.message || streamError }, "Ollama streaming failed")
|
||||
// Send error event to client so it knows the request failed
|
||||
reply.raw.write(`data: ${JSON.stringify({ error: streamError?.message || "Streaming failed" })}\n\n`)
|
||||
reply.raw.write('data: [DONE]\n\n')
|
||||
reply.raw.end()
|
||||
}
|
||||
} else {
|
||||
const stream = await client.chat(chatRequest)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks[chunks.length - 1]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Ollama chat request failed")
|
||||
return reply.status(500).send({ error: "Chat request failed" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/ollama/chat/thinking', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['model', 'messages'],
|
||||
properties: {
|
||||
model: { type: 'string' },
|
||||
messages: { type: 'array' },
|
||||
stream: { type: 'boolean' },
|
||||
think: { type: ['boolean', 'string'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||
}
|
||||
|
||||
const client = new OllamaCloudClient(config)
|
||||
const chatRequest = request.body as ChatRequest
|
||||
chatRequest.think = chatRequest.think ?? true
|
||||
|
||||
if (chatRequest.stream) {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
try {
|
||||
const stream = await client.chatWithThinking(chatRequest)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
|
||||
if (chunk.done) {
|
||||
reply.raw.write('data: [DONE]\n\n')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
reply.raw.end()
|
||||
} catch (streamError) {
|
||||
logger.error({ error: streamError }, "Thinking streaming failed")
|
||||
reply.raw.end()
|
||||
}
|
||||
} else {
|
||||
const stream = await client.chatWithThinking(chatRequest)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks[chunks.length - 1]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Ollama thinking chat request failed")
|
||||
return reply.status(500).send({ error: "Thinking chat request failed" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/ollama/chat/vision', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['model', 'messages', 'images'],
|
||||
properties: {
|
||||
model: { type: 'string' },
|
||||
messages: { type: 'array' },
|
||||
images: { type: 'array', items: { type: 'string' } },
|
||||
stream: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||
}
|
||||
|
||||
const client = new OllamaCloudClient(config)
|
||||
const { model, messages, images, stream } = request.body as any
|
||||
const chatRequest: ChatRequest = { model, messages, stream: stream ?? false }
|
||||
|
||||
if (chatRequest.stream) {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
try {
|
||||
const streamResult = await client.chatWithVision(chatRequest, images)
|
||||
|
||||
for await (const chunk of streamResult) {
|
||||
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
|
||||
if (chunk.done) {
|
||||
reply.raw.write('data: [DONE]\n\n')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
reply.raw.end()
|
||||
} catch (streamError) {
|
||||
logger.error({ error: streamError }, "Vision streaming failed")
|
||||
reply.raw.end()
|
||||
}
|
||||
} else {
|
||||
const streamResult = await client.chatWithVision(chatRequest, images)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of streamResult) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks[chunks.length - 1]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Ollama vision chat request failed")
|
||||
return reply.status(500).send({ error: "Vision chat request failed" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/ollama/chat/tools', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['model', 'messages', 'tools'],
|
||||
properties: {
|
||||
model: { type: 'string' },
|
||||
messages: { type: 'array' },
|
||||
tools: { type: 'array' },
|
||||
stream: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||
}
|
||||
|
||||
const client = new OllamaCloudClient(config)
|
||||
const { model, messages, tools, stream } = request.body as any
|
||||
const chatRequest: ChatRequest = { model, messages, stream: stream ?? false }
|
||||
|
||||
if (chatRequest.stream) {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
try {
|
||||
const streamResult = await client.chatWithTools(chatRequest, tools)
|
||||
|
||||
for await (const chunk of streamResult) {
|
||||
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
|
||||
if (chunk.done) {
|
||||
reply.raw.write('data: [DONE]\n\n')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
reply.raw.end()
|
||||
} catch (streamError) {
|
||||
logger.error({ error: streamError }, "Tools streaming failed")
|
||||
reply.raw.end()
|
||||
}
|
||||
} else {
|
||||
const streamResult = await client.chatWithTools(chatRequest, tools)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of streamResult) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks[chunks.length - 1]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Ollama tools chat request failed")
|
||||
return reply.status(500).send({ error: "Tools chat request failed" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/ollama/chat/websearch', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['model', 'messages'],
|
||||
properties: {
|
||||
model: { type: 'string' },
|
||||
messages: { type: 'array' },
|
||||
stream: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||
}
|
||||
|
||||
const client = new OllamaCloudClient(config)
|
||||
const chatRequest = request.body as ChatRequest
|
||||
|
||||
if (chatRequest.stream) {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
try {
|
||||
const stream = await client.chatWithWebSearch(chatRequest)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
|
||||
if (chunk.done) {
|
||||
reply.raw.write('data: [DONE]\n\n')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
reply.raw.end()
|
||||
} catch (streamError) {
|
||||
logger.error({ error: streamError }, "Web search streaming failed")
|
||||
reply.raw.end()
|
||||
}
|
||||
} else {
|
||||
const stream = await client.chatWithWebSearch(chatRequest)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks[chunks.length - 1]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Ollama web search chat request failed")
|
||||
return reply.status(500).send({ error: "Web search chat request failed" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/ollama/embeddings', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['model', 'input'],
|
||||
properties: {
|
||||
model: { type: 'string' },
|
||||
input: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||
}
|
||||
|
||||
const client = new OllamaCloudClient(config)
|
||||
const embedRequest = request.body as EmbeddingRequest
|
||||
|
||||
const result = await client.generateEmbeddings(embedRequest)
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Ollama embeddings request failed")
|
||||
return reply.status(500).send({ error: "Embeddings request failed" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/ollama/pull', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['model'],
|
||||
properties: {
|
||||
model: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const config = getOllamaConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
|
||||
}
|
||||
|
||||
const client = new OllamaCloudClient(config)
|
||||
const { model } = request.body as any
|
||||
|
||||
client.pullModel(model).catch(error => {
|
||||
logger.error({ error, model }, "Failed to pull model")
|
||||
})
|
||||
|
||||
return { message: `Started pulling model: ${model}` }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to initiate model pull")
|
||||
return reply.status(500).send({ error: "Failed to start model pull" })
|
||||
}
|
||||
})
|
||||
|
||||
logger.info("Ollama Cloud routes registered")
|
||||
}
|
||||
|
||||
function getOllamaConfig(): OllamaCloudConfig {
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
return { enabled: false, endpoint: "https://ollama.com" }
|
||||
}
|
||||
const data = fs.readFileSync(CONFIG_FILE, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
} catch {
|
||||
return { enabled: false, endpoint: "https://ollama.com" }
|
||||
}
|
||||
}
|
||||
|
||||
function updateOllamaConfig(config: Partial<OllamaCloudConfig>): void {
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_DIR)) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
||||
}
|
||||
const current = getOllamaConfig()
|
||||
|
||||
// Only update apiKey if a new non-empty value is provided
|
||||
const updated = {
|
||||
...current,
|
||||
...config,
|
||||
// Preserve existing apiKey if new one is undefined/empty
|
||||
apiKey: config.apiKey || current.apiKey
|
||||
}
|
||||
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2))
|
||||
console.log(`[Ollama] Config saved: enabled=${updated.enabled}, endpoint=${updated.endpoint}, hasApiKey=${!!updated.apiKey}`)
|
||||
} catch (error) {
|
||||
console.error("Failed to save Ollama config:", error)
|
||||
}
|
||||
}
|
||||
324
packages/server/src/server/routes/opencode-zen.ts
Normal file
324
packages/server/src/server/routes/opencode-zen.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { OpenCodeZenClient, type ChatRequest, getDefaultZenConfig, type ChatMessage } from "../../integrations/opencode-zen"
|
||||
import { Logger } from "../../logger"
|
||||
import { CORE_TOOLS, executeTools, type ToolCall, type ToolResult } from "../../tools/executor"
|
||||
import { getMcpManager } from "../../mcp/client"
|
||||
|
||||
interface OpenCodeZenRouteDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
// Maximum number of tool execution loops
|
||||
const MAX_TOOL_LOOPS = 10
|
||||
|
||||
export async function registerOpenCodeZenRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: OpenCodeZenRouteDeps
|
||||
) {
|
||||
const logger = deps.logger.child({ component: "opencode-zen-routes" })
|
||||
|
||||
// Create shared client
|
||||
const client = new OpenCodeZenClient(getDefaultZenConfig())
|
||||
|
||||
// List available free Zen models
|
||||
app.get('/api/opencode-zen/models', async (request, reply) => {
|
||||
try {
|
||||
const models = await client.getModels()
|
||||
|
||||
return {
|
||||
models: models.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
family: m.family,
|
||||
provider: "opencode-zen",
|
||||
free: true,
|
||||
reasoning: m.reasoning,
|
||||
tool_call: m.tool_call,
|
||||
limit: m.limit
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to list OpenCode Zen models")
|
||||
return reply.status(500).send({ error: "Failed to list models" })
|
||||
}
|
||||
})
|
||||
|
||||
// Test connection
|
||||
app.get('/api/opencode-zen/test', async (request, reply) => {
|
||||
try {
|
||||
const connected = await client.testConnection()
|
||||
return { connected }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "OpenCode Zen connection test failed")
|
||||
return reply.status(500).send({ error: "Connection test failed" })
|
||||
}
|
||||
})
|
||||
|
||||
// Chat completion endpoint WITH MCP TOOL SUPPORT
|
||||
app.post('/api/opencode-zen/chat', async (request, reply) => {
|
||||
try {
|
||||
const chatRequest = request.body as ChatRequest & {
|
||||
workspacePath?: string
|
||||
enableTools?: boolean
|
||||
}
|
||||
|
||||
// Extract workspace path for tool execution
|
||||
const workspacePath = chatRequest.workspacePath || process.cwd()
|
||||
const enableTools = chatRequest.enableTools !== false
|
||||
|
||||
logger.info({
|
||||
workspacePath,
|
||||
receivedWorkspacePath: chatRequest.workspacePath,
|
||||
enableTools
|
||||
}, "OpenCode Zen chat request received")
|
||||
|
||||
// Handle streaming with tool loop
|
||||
if (chatRequest.stream) {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
try {
|
||||
await streamWithToolLoop(
|
||||
client,
|
||||
chatRequest,
|
||||
workspacePath,
|
||||
enableTools,
|
||||
reply.raw,
|
||||
logger
|
||||
)
|
||||
reply.raw.end()
|
||||
} catch (streamError) {
|
||||
logger.error({ error: streamError }, "OpenCode Zen streaming failed")
|
||||
reply.raw.write(`data: ${JSON.stringify({ error: String(streamError) })}\n\n`)
|
||||
reply.raw.end()
|
||||
}
|
||||
} else {
|
||||
// Non-streaming with tool loop
|
||||
const response = await chatWithToolLoop(
|
||||
client,
|
||||
chatRequest,
|
||||
workspacePath,
|
||||
enableTools,
|
||||
logger
|
||||
)
|
||||
return response
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "OpenCode Zen chat request failed")
|
||||
return reply.status(500).send({ error: "Chat request failed" })
|
||||
}
|
||||
})
|
||||
|
||||
logger.info("OpenCode Zen routes registered with MCP tool support - Free models available!")
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat with tool execution loop
|
||||
*/
|
||||
async function streamWithToolLoop(
|
||||
client: OpenCodeZenClient,
|
||||
request: ChatRequest,
|
||||
workspacePath: string,
|
||||
enableTools: boolean,
|
||||
rawResponse: any,
|
||||
logger: Logger
|
||||
): Promise<void> {
|
||||
let messages = [...request.messages]
|
||||
let loopCount = 0
|
||||
|
||||
// Load MCP tools from workspace config
|
||||
let allTools = [...CORE_TOOLS]
|
||||
if (enableTools && workspacePath) {
|
||||
try {
|
||||
const mcpManager = getMcpManager()
|
||||
await mcpManager.loadConfig(workspacePath)
|
||||
const mcpTools = await mcpManager.getToolsAsOpenAIFormat()
|
||||
allTools = [...CORE_TOOLS, ...mcpTools]
|
||||
if (mcpTools.length > 0) {
|
||||
logger.info({ mcpToolCount: mcpTools.length }, "Loaded MCP tools for OpenCode Zen")
|
||||
}
|
||||
} catch (mcpError) {
|
||||
logger.warn({ error: mcpError }, "Failed to load MCP tools")
|
||||
}
|
||||
}
|
||||
|
||||
// Inject tools if enabled
|
||||
const requestWithTools: ChatRequest = {
|
||||
...request,
|
||||
tools: enableTools ? allTools : undefined,
|
||||
tool_choice: enableTools ? "auto" : undefined
|
||||
}
|
||||
|
||||
while (loopCount < MAX_TOOL_LOOPS) {
|
||||
loopCount++
|
||||
|
||||
// Accumulate tool calls from stream
|
||||
let accumulatedToolCalls: { [index: number]: { id: string; name: string; arguments: string } } = {}
|
||||
let hasToolCalls = false
|
||||
let textContent = ""
|
||||
|
||||
// Stream response
|
||||
for await (const chunk of client.chatStream({ ...requestWithTools, messages })) {
|
||||
// Write chunk to client
|
||||
rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
|
||||
const choice = chunk.choices[0]
|
||||
if (!choice) continue
|
||||
|
||||
// Accumulate text content
|
||||
if (choice.delta?.content) {
|
||||
textContent += choice.delta.content
|
||||
}
|
||||
|
||||
// Accumulate tool calls from delta (if API supports it)
|
||||
const deltaToolCalls = (choice.delta as any)?.tool_calls
|
||||
if (deltaToolCalls) {
|
||||
hasToolCalls = true
|
||||
for (const tc of deltaToolCalls) {
|
||||
const idx = tc.index ?? 0
|
||||
if (!accumulatedToolCalls[idx]) {
|
||||
accumulatedToolCalls[idx] = { id: tc.id || "", name: "", arguments: "" }
|
||||
}
|
||||
if (tc.id) accumulatedToolCalls[idx].id = tc.id
|
||||
if (tc.function?.name) accumulatedToolCalls[idx].name += tc.function.name
|
||||
if (tc.function?.arguments) accumulatedToolCalls[idx].arguments += tc.function.arguments
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should stop
|
||||
if (choice.finish_reason === "stop") {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no tool calls, we're done
|
||||
if (!hasToolCalls || !enableTools) {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
|
||||
// Convert accumulated tool calls
|
||||
const toolCalls: ToolCall[] = Object.values(accumulatedToolCalls).map(tc => ({
|
||||
id: tc.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments
|
||||
}
|
||||
}))
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info({ toolCalls: toolCalls.map(tc => tc.function.name) }, "Executing tool calls")
|
||||
|
||||
// Add assistant message with tool calls
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: textContent || undefined,
|
||||
tool_calls: toolCalls
|
||||
}
|
||||
messages.push(assistantMessage)
|
||||
|
||||
// Execute tools
|
||||
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||
|
||||
// Notify client about tool execution via special event
|
||||
for (const result of toolResults) {
|
||||
const toolEvent = {
|
||||
type: "tool_result",
|
||||
tool_call_id: result.tool_call_id,
|
||||
content: result.content
|
||||
}
|
||||
rawResponse.write(`data: ${JSON.stringify(toolEvent)}\n\n`)
|
||||
}
|
||||
|
||||
// Add tool results to messages
|
||||
for (const result of toolResults) {
|
||||
const toolMessage: ChatMessage = {
|
||||
role: "tool",
|
||||
content: result.content,
|
||||
tool_call_id: result.tool_call_id
|
||||
}
|
||||
messages.push(toolMessage)
|
||||
}
|
||||
|
||||
logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete")
|
||||
}
|
||||
|
||||
logger.warn({ loopCount }, "Max tool loops reached")
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming chat with tool execution loop
|
||||
*/
|
||||
async function chatWithToolLoop(
|
||||
client: OpenCodeZenClient,
|
||||
request: ChatRequest,
|
||||
workspacePath: string,
|
||||
enableTools: boolean,
|
||||
logger: Logger
|
||||
): Promise<any> {
|
||||
let messages = [...request.messages]
|
||||
let loopCount = 0
|
||||
let lastResponse: any = null
|
||||
|
||||
// Inject tools if enabled
|
||||
const requestWithTools: ChatRequest = {
|
||||
...request,
|
||||
tools: enableTools ? CORE_TOOLS : undefined,
|
||||
tool_choice: enableTools ? "auto" : undefined
|
||||
}
|
||||
|
||||
while (loopCount < MAX_TOOL_LOOPS) {
|
||||
loopCount++
|
||||
|
||||
const response = await client.chat({ ...requestWithTools, messages, stream: false })
|
||||
lastResponse = response
|
||||
|
||||
const choice = response.choices[0]
|
||||
if (!choice) break
|
||||
|
||||
const toolCalls = (choice.message as any)?.tool_calls
|
||||
|
||||
// If no tool calls, return
|
||||
if (!toolCalls || toolCalls.length === 0 || !enableTools) {
|
||||
return response
|
||||
}
|
||||
|
||||
logger.info({ toolCalls: toolCalls.map((tc: any) => tc.function.name) }, "Executing tool calls")
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: (choice.message as any).content || undefined,
|
||||
tool_calls: toolCalls
|
||||
}
|
||||
messages.push(assistantMessage)
|
||||
|
||||
// Execute tools
|
||||
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||
|
||||
// Add tool results
|
||||
for (const result of toolResults) {
|
||||
const toolMessage: ChatMessage = {
|
||||
role: "tool",
|
||||
content: result.content,
|
||||
tool_call_id: result.tool_call_id
|
||||
}
|
||||
messages.push(toolMessage)
|
||||
}
|
||||
|
||||
logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete")
|
||||
}
|
||||
|
||||
logger.warn({ loopCount }, "Max tool loops reached")
|
||||
return lastResponse
|
||||
}
|
||||
478
packages/server/src/server/routes/qwen.ts
Normal file
478
packages/server/src/server/routes/qwen.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import { FastifyInstance, FastifyReply } from "fastify"
|
||||
import { join } from "path"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { Logger } from "../../logger"
|
||||
import { CORE_TOOLS, executeTools, type ToolCall, type ToolResult } from "../../tools/executor"
|
||||
import { getMcpManager } from "../../mcp/client"
|
||||
|
||||
interface QwenRouteDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const MAX_TOOL_LOOPS = 10
|
||||
|
||||
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'
|
||||
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`
|
||||
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`
|
||||
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56'
|
||||
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion'
|
||||
const QWEN_OAUTH_DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
const QWEN_DEFAULT_RESOURCE_URL = 'https://dashscope.aliyuncs.com/compatible-mode'
|
||||
|
||||
function normalizeQwenModel(model?: string): string {
|
||||
const raw = (model || "").trim()
|
||||
if (!raw) return "coder-model"
|
||||
const lower = raw.toLowerCase()
|
||||
if (lower === "vision-model" || lower.includes("vision")) return "vision-model"
|
||||
if (lower === "coder-model") return "coder-model"
|
||||
if (lower.includes("coder")) return "coder-model"
|
||||
return "coder-model"
|
||||
}
|
||||
|
||||
function normalizeQwenResourceUrl(resourceUrl?: string): string {
|
||||
const raw = typeof resourceUrl === 'string' && resourceUrl.trim().length > 0
|
||||
? resourceUrl.trim()
|
||||
: QWEN_DEFAULT_RESOURCE_URL
|
||||
const withProtocol = raw.startsWith('http') ? raw : `https://${raw}`
|
||||
const trimmed = withProtocol.replace(/\/$/, '')
|
||||
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`
|
||||
}
|
||||
|
||||
export async function registerQwenRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: QwenRouteDeps
|
||||
) {
|
||||
const logger = deps.logger.child({ component: "qwen-routes" })
|
||||
|
||||
// Qwen OAuth Device Flow: request device authorization
|
||||
app.post('/api/qwen/oauth/device', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['code_challenge', 'code_challenge_method'],
|
||||
properties: {
|
||||
code_challenge: { type: 'string' },
|
||||
code_challenge_method: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { code_challenge, code_challenge_method } = request.body as any
|
||||
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||
scope: QWEN_OAUTH_SCOPE,
|
||||
code_challenge,
|
||||
code_challenge_method
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error({ status: response.status, errorText }, "Qwen device authorization failed")
|
||||
return reply.status(response.status).send({ error: "Device authorization failed", details: errorText })
|
||||
}
|
||||
|
||||
const 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" })
|
||||
}
|
||||
})
|
||||
|
||||
// Qwen OAuth Device Flow: poll token endpoint
|
||||
app.post('/api/qwen/oauth/token', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['device_code', 'code_verifier'],
|
||||
properties: {
|
||||
device_code: { type: 'string' },
|
||||
code_verifier: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { device_code, code_verifier } = request.body as any
|
||||
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: QWEN_OAUTH_DEVICE_GRANT_TYPE,
|
||||
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||
device_code,
|
||||
code_verifier
|
||||
})
|
||||
})
|
||||
|
||||
const responseText = await response.text()
|
||||
if (!response.ok) {
|
||||
logger.error({ status: response.status, responseText }, "Qwen device token poll failed")
|
||||
return reply.status(response.status).send(responseText)
|
||||
}
|
||||
try {
|
||||
return reply.send(JSON.parse(responseText))
|
||||
} catch {
|
||||
return reply.send(responseText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to poll Qwen token endpoint")
|
||||
return reply.status(500).send({ error: "Token polling failed" })
|
||||
}
|
||||
})
|
||||
|
||||
// Qwen OAuth refresh token
|
||||
app.post('/api/qwen/oauth/refresh', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['refresh_token'],
|
||||
properties: {
|
||||
refresh_token: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { refresh_token } = request.body as any
|
||||
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token,
|
||||
client_id: QWEN_OAUTH_CLIENT_ID
|
||||
})
|
||||
})
|
||||
|
||||
const responseText = await response.text()
|
||||
if (!response.ok) {
|
||||
logger.error({ status: response.status, responseText }, "Qwen token refresh failed")
|
||||
return reply.status(response.status).send(responseText)
|
||||
}
|
||||
|
||||
try {
|
||||
return reply.send(JSON.parse(responseText))
|
||||
} catch {
|
||||
return reply.send(responseText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to refresh Qwen token")
|
||||
return reply.status(500).send({ error: "Token refresh failed" })
|
||||
}
|
||||
})
|
||||
|
||||
// Get user info
|
||||
app.get('/api/qwen/user', async (request, reply) => {
|
||||
try {
|
||||
const authHeader = request.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.status(401).send({ error: "Authorization required" })
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7)
|
||||
const userResponse = await fetch('https://chat.qwen.ai/api/v1/user', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!userResponse.ok) {
|
||||
return reply.status(401).send({ error: "Invalid token" })
|
||||
}
|
||||
|
||||
const userData = await userResponse.json()
|
||||
return { user: userData }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to fetch Qwen user info")
|
||||
return reply.status(500).send({ error: "Failed to fetch user info" })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Streaming chat with tool execution loop for Qwen
|
||||
*/
|
||||
async function streamWithToolLoop(
|
||||
accessToken: string,
|
||||
chatUrl: string,
|
||||
initialRequest: any,
|
||||
workspacePath: string,
|
||||
enableTools: boolean,
|
||||
rawResponse: any,
|
||||
logger: Logger
|
||||
) {
|
||||
let messages = [...initialRequest.messages]
|
||||
let loopCount = 0
|
||||
const model = initialRequest.model
|
||||
|
||||
while (loopCount < MAX_TOOL_LOOPS) {
|
||||
loopCount++
|
||||
logger.info({ loopCount, model }, "Starting Qwen tool loop iteration")
|
||||
|
||||
const response = await fetch(chatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...initialRequest,
|
||||
messages,
|
||||
stream: true,
|
||||
tools: enableTools ? initialRequest.tools : undefined,
|
||||
tool_choice: enableTools ? "auto" : undefined
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Qwen API error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
if (!response.body) throw new Error("No response body")
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let textContent = ""
|
||||
let hasToolCalls = false
|
||||
let accumulatedToolCalls: Record<number, { id: string, name: string, arguments: string }> = {}
|
||||
let buffer = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith("data: ")) continue
|
||||
const data = trimmed.slice(6).trim()
|
||||
if (data === "[DONE]") {
|
||||
if (!hasToolCalls) {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
let chunk: any
|
||||
try {
|
||||
chunk = JSON.parse(data)
|
||||
} catch (e) {
|
||||
continue
|
||||
}
|
||||
|
||||
const choice = chunk.choices?.[0]
|
||||
if (!choice) continue
|
||||
|
||||
// Pass through text content to client
|
||||
if (choice.delta?.content) {
|
||||
textContent += choice.delta.content
|
||||
rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
}
|
||||
|
||||
// Accumulate tool calls
|
||||
if (choice.delta?.tool_calls) {
|
||||
hasToolCalls = true
|
||||
for (const tc of choice.delta.tool_calls) {
|
||||
const idx = tc.index ?? 0
|
||||
if (!accumulatedToolCalls[idx]) {
|
||||
accumulatedToolCalls[idx] = { id: tc.id || "", name: "", arguments: "" }
|
||||
}
|
||||
if (tc.id) accumulatedToolCalls[idx].id = tc.id
|
||||
if (tc.function?.name) accumulatedToolCalls[idx].name += tc.function.name
|
||||
if (tc.function?.arguments) accumulatedToolCalls[idx].arguments += tc.function.arguments
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason === "tool_calls") {
|
||||
break
|
||||
}
|
||||
|
||||
if (choice.finish_reason === "stop" && !hasToolCalls) {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no tool calls, we're done
|
||||
if (!hasToolCalls || !enableTools) {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
|
||||
// Execute tools
|
||||
const toolCalls: ToolCall[] = Object.values(accumulatedToolCalls).map(tc => ({
|
||||
id: tc.id,
|
||||
type: "function" as const,
|
||||
function: { name: tc.name, arguments: tc.arguments }
|
||||
}))
|
||||
|
||||
logger.info({ toolCalls: toolCalls.map(tc => tc.function.name) }, "Executing Qwen tool calls")
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: textContent || undefined,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
|
||||
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||
|
||||
// Notify frontend
|
||||
for (const result of toolResults) {
|
||||
const toolEvent = {
|
||||
type: "tool_result",
|
||||
tool_call_id: result.tool_call_id,
|
||||
content: result.content
|
||||
}
|
||||
rawResponse.write(`data: ${JSON.stringify(toolEvent)}\n\n`)
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: result.content,
|
||||
tool_call_id: result.tool_call_id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
}
|
||||
|
||||
// Qwen Chat API - with tool support
|
||||
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' },
|
||||
workspacePath: { type: 'string' },
|
||||
enableTools: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const authHeader = request.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.status(401).send({ error: "Authorization required" })
|
||||
}
|
||||
|
||||
const accessToken = authHeader.substring(7)
|
||||
const { model, messages, stream, resource_url, workspacePath, enableTools } = request.body as any
|
||||
|
||||
const apiBaseUrl = normalizeQwenResourceUrl(resource_url)
|
||||
const normalizedModel = normalizeQwenModel(model)
|
||||
const chatUrl = `${apiBaseUrl}/chat/completions`
|
||||
|
||||
// MCP Tool Loading
|
||||
let allTools = [...CORE_TOOLS]
|
||||
const effectiveWorkspacePath = workspacePath || process.cwd()
|
||||
const toolsEnabled = enableTools !== false
|
||||
|
||||
if (toolsEnabled && effectiveWorkspacePath) {
|
||||
try {
|
||||
const mcpManager = getMcpManager()
|
||||
await mcpManager.loadConfig(effectiveWorkspacePath)
|
||||
const mcpTools = await mcpManager.getToolsAsOpenAIFormat()
|
||||
allTools = [...CORE_TOOLS, ...mcpTools]
|
||||
} catch (mcpError) {
|
||||
logger.warn({ error: mcpError }, "Failed to load MCP tools for Qwen")
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ chatUrl, model: normalizedModel, tools: allTools.length }, "Proxying Qwen chat with tools")
|
||||
|
||||
if (stream) {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
await streamWithToolLoop(
|
||||
accessToken,
|
||||
chatUrl,
|
||||
{ model: normalizedModel, messages, tools: allTools },
|
||||
effectiveWorkspacePath,
|
||||
toolsEnabled,
|
||||
reply.raw,
|
||||
logger
|
||||
)
|
||||
} else {
|
||||
const response = await fetch(chatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: normalizedModel,
|
||||
messages,
|
||||
stream: false
|
||||
})
|
||||
})
|
||||
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")
|
||||
}
|
||||
141
packages/server/src/server/routes/skills.ts
Normal file
141
packages/server/src/server/routes/skills.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { SkillCatalogResponse, SkillDetail, SkillDescriptor } from "../../api-types"
|
||||
|
||||
const SKILLS_OWNER = "anthropics"
|
||||
const SKILLS_REPO = "skills"
|
||||
const SKILLS_BRANCH = "main"
|
||||
const SKILLS_ROOT = "skills"
|
||||
const CATALOG_TTL_MS = 30 * 60 * 1000
|
||||
const DETAIL_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
type CachedCatalog = { skills: SkillDescriptor[]; fetchedAt: number }
|
||||
type CachedDetail = { detail: SkillDetail; fetchedAt: number }
|
||||
|
||||
let catalogCache: CachedCatalog | null = null
|
||||
const detailCache = new Map<string, CachedDetail>()
|
||||
|
||||
interface RepoEntry {
|
||||
name: string
|
||||
path: string
|
||||
type: "file" | "dir"
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown: string): { attributes: Record<string, string>; body: string } {
|
||||
if (!markdown.startsWith("---")) {
|
||||
return { attributes: {}, body: markdown.trim() }
|
||||
}
|
||||
const end = markdown.indexOf("\n---", 3)
|
||||
if (end === -1) {
|
||||
return { attributes: {}, body: markdown.trim() }
|
||||
}
|
||||
const frontmatter = markdown.slice(3, end).trim()
|
||||
const body = markdown.slice(end + 4).trimStart()
|
||||
const attributes: Record<string, string> = {}
|
||||
for (const line of frontmatter.split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
|
||||
if (!match) continue
|
||||
const key = match[1].trim()
|
||||
const value = match[2]?.trim() ?? ""
|
||||
attributes[key] = value
|
||||
}
|
||||
return { attributes, body }
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": "NomadArch-Skills" },
|
||||
})
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Request failed (${response.status})`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": "NomadArch-Skills" },
|
||||
})
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Request failed (${response.status})`)
|
||||
}
|
||||
return await response.text()
|
||||
}
|
||||
|
||||
async function fetchCatalog(): Promise<SkillDescriptor[]> {
|
||||
const url = `https://api.github.com/repos/${SKILLS_OWNER}/${SKILLS_REPO}/contents/${SKILLS_ROOT}?ref=${SKILLS_BRANCH}`
|
||||
const entries = await fetchJson<RepoEntry[]>(url)
|
||||
const directories = entries.filter((entry) => entry.type === "dir")
|
||||
const results: SkillDescriptor[] = []
|
||||
|
||||
for (const dir of directories) {
|
||||
try {
|
||||
const skill = await fetchSkillDetail(dir.name)
|
||||
results.push({ id: skill.id, name: skill.name, description: skill.description })
|
||||
} catch {
|
||||
results.push({ id: dir.name, name: dir.name, description: "" })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function fetchSkillDetail(id: string): Promise<SkillDetail> {
|
||||
const markdownUrl = `https://raw.githubusercontent.com/${SKILLS_OWNER}/${SKILLS_REPO}/${SKILLS_BRANCH}/${SKILLS_ROOT}/${id}/SKILL.md`
|
||||
const markdown = await fetchText(markdownUrl)
|
||||
const parsed = parseFrontmatter(markdown)
|
||||
const name = parsed.attributes.name || id
|
||||
const description = parsed.attributes.description || ""
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
content: parsed.body.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
async function getCatalogCached(): Promise<SkillDescriptor[]> {
|
||||
const now = Date.now()
|
||||
if (catalogCache && now - catalogCache.fetchedAt < CATALOG_TTL_MS) {
|
||||
return catalogCache.skills
|
||||
}
|
||||
const skills = await fetchCatalog()
|
||||
catalogCache = { skills, fetchedAt: now }
|
||||
return skills
|
||||
}
|
||||
|
||||
async function getDetailCached(id: string): Promise<SkillDetail> {
|
||||
const now = Date.now()
|
||||
const cached = detailCache.get(id)
|
||||
if (cached && now - cached.fetchedAt < DETAIL_TTL_MS) {
|
||||
return cached.detail
|
||||
}
|
||||
const detail = await fetchSkillDetail(id)
|
||||
detailCache.set(id, { detail, fetchedAt: now })
|
||||
return detail
|
||||
}
|
||||
|
||||
export async function registerSkillsRoutes(app: FastifyInstance) {
|
||||
app.get("/api/skills/catalog", async (): Promise<SkillCatalogResponse> => {
|
||||
const skills = await getCatalogCached()
|
||||
return { skills }
|
||||
})
|
||||
|
||||
app.get<{ Querystring: { id?: string } }>("/api/skills/detail", async (request, reply): Promise<SkillDetail> => {
|
||||
const query = z.object({ id: z.string().min(1) }).parse(request.query ?? {})
|
||||
try {
|
||||
return await getDetailCached(query.id)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error, skillId: query.id }, "Failed to fetch skill detail")
|
||||
reply.code(502)
|
||||
return {
|
||||
id: query.id,
|
||||
name: query.id,
|
||||
description: "",
|
||||
content: "Unable to load skill content.",
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
93
packages/server/src/server/routes/storage.ts
Normal file
93
packages/server/src/server/routes/storage.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { InstanceStore } from "../../storage/instance-store"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { ModelPreferenceSchema } from "../../config/schema"
|
||||
import type { InstanceData, Task, SessionTasks } from "../../api-types"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
|
||||
interface RouteDeps {
|
||||
instanceStore: InstanceStore
|
||||
eventBus: EventBus
|
||||
workspaceManager: WorkspaceManager
|
||||
}
|
||||
|
||||
const TaskSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
status: z.enum(["completed", "interrupted", "in-progress", "pending"]),
|
||||
timestamp: z.number(),
|
||||
messageIds: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
const InstanceDataSchema = z.object({
|
||||
messageHistory: z.array(z.string()).default([]),
|
||||
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
|
||||
sessionTasks: z.record(z.string(), z.array(TaskSchema)).optional(),
|
||||
sessionSkills: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.array(z.object({ id: z.string(), name: z.string(), description: z.string().optional() })),
|
||||
)
|
||||
.optional(),
|
||||
customAgents: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
prompt: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const EMPTY_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
agentModelSelections: {},
|
||||
sessionTasks: {},
|
||||
sessionSkills: {},
|
||||
customAgents: [],
|
||||
}
|
||||
|
||||
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const resolveStorageKey = (instanceId: string): string => {
|
||||
const workspace = deps.workspaceManager.get(instanceId)
|
||||
return workspace?.path ?? instanceId
|
||||
}
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const storageId = resolveStorageKey(request.params.id)
|
||||
const data = await deps.instanceStore.read(storageId)
|
||||
return data
|
||||
} catch (error) {
|
||||
reply.code(500)
|
||||
return { error: error instanceof Error ? error.message : "Failed to read instance data" }
|
||||
}
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const body = InstanceDataSchema.parse(request.body ?? {})
|
||||
const storageId = resolveStorageKey(request.params.id)
|
||||
await deps.instanceStore.write(storageId, body)
|
||||
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: body })
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Failed to save instance data" }
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const storageId = resolveStorageKey(request.params.id)
|
||||
await deps.instanceStore.delete(storageId)
|
||||
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: EMPTY_INSTANCE_DATA })
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
reply.code(500)
|
||||
return { error: error instanceof Error ? error.message : "Failed to delete instance data" }
|
||||
}
|
||||
})
|
||||
}
|
||||
487
packages/server/src/server/routes/workspaces.ts
Normal file
487
packages/server/src/server/routes/workspaces.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { FastifyInstance, FastifyReply } from "fastify"
|
||||
import { spawnSync } from "child_process"
|
||||
import { z } from "zod"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { cp, readFile, writeFile, stat as readFileStat } from "fs/promises"
|
||||
import path from "path"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
import { InstanceStore } from "../../storage/instance-store"
|
||||
import { ConfigStore } from "../../config/store"
|
||||
import { getWorkspaceOpencodeConfigDir } from "../../opencode-config"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
instanceStore: InstanceStore
|
||||
configStore: ConfigStore
|
||||
}
|
||||
|
||||
const WorkspaceCreateSchema = z.object({
|
||||
path: z.string(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
const WorkspaceFilesQuerySchema = z.object({
|
||||
path: z.string().optional(),
|
||||
})
|
||||
|
||||
const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
type: z.enum(["all", "file", "directory"]).optional(),
|
||||
refresh: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value === undefined ? undefined : value === "true")),
|
||||
})
|
||||
|
||||
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/workspaces", async () => {
|
||||
return deps.workspaceManager.list()
|
||||
})
|
||||
|
||||
app.post("/api/workspaces", async (request, reply) => {
|
||||
try {
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
reply.code(201)
|
||||
return workspace
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to create workspace")
|
||||
const message = error instanceof Error ? error.message : "Failed to create workspace"
|
||||
reply.code(400).type("text/plain").send(message)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
return workspace
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||
await deps.workspaceManager.delete(request.params.id)
|
||||
reply.code(204)
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
}>("/api/workspaces/:id/files", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFilesQuerySchema.parse(request.query ?? {})
|
||||
return deps.workspaceManager.listFiles(request.params.id, query.path ?? ".")
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { q?: string; limit?: string; type?: "all" | "file" | "directory"; refresh?: string }
|
||||
}>("/api/workspaces/:id/files/search", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {})
|
||||
return deps.workspaceManager.searchFiles(request.params.id, query.q, {
|
||||
limit: query.limit,
|
||||
type: query.type,
|
||||
refresh: query.refresh,
|
||||
})
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||
return deps.workspaceManager.readFile(request.params.id, query.path)
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id/git/status", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
const result = spawnSync("git", ["-C", workspace.path, "status", "--porcelain=v1", "-b"], { encoding: "utf8" })
|
||||
if (result.error) {
|
||||
return {
|
||||
isRepo: false,
|
||||
branch: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
changes: [],
|
||||
error: result.error.message,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || "").toLowerCase()
|
||||
if (stderr.includes("not a git repository")) {
|
||||
return { isRepo: false, branch: null, ahead: 0, behind: 0, changes: [] }
|
||||
}
|
||||
reply.code(400)
|
||||
return {
|
||||
isRepo: false,
|
||||
branch: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
changes: [],
|
||||
error: result.stderr || "Unable to read git status",
|
||||
}
|
||||
}
|
||||
|
||||
const lines = (result.stdout || "").split(/\r?\n/).filter((line) => line.trim().length > 0)
|
||||
let branch: string | null = null
|
||||
let ahead = 0
|
||||
let behind = 0
|
||||
const changes: Array<{ path: string; status: string }> = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("##")) {
|
||||
const header = line.replace(/^##\s*/, "")
|
||||
const [branchPart, trackingPart] = header.split("...")
|
||||
branch = branchPart?.trim() || null
|
||||
const tracking = trackingPart || ""
|
||||
const aheadMatch = tracking.match(/ahead\s+(\d+)/)
|
||||
const behindMatch = tracking.match(/behind\s+(\d+)/)
|
||||
ahead = aheadMatch ? Number(aheadMatch[1]) : 0
|
||||
behind = behindMatch ? Number(behindMatch[1]) : 0
|
||||
continue
|
||||
}
|
||||
|
||||
const status = line.slice(0, 2).trim() || line.slice(0, 2)
|
||||
const path = line.slice(3).trim()
|
||||
changes.push({ path, status })
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
// Auto-load MCP config into the manager after saving
|
||||
const { getMcpManager } = await import("../../mcp/client")
|
||||
const mcpManager = getMcpManager()
|
||||
await mcpManager.loadConfig(workspace.path)
|
||||
|
||||
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" }
|
||||
}
|
||||
})
|
||||
|
||||
// Get MCP connection status for a workspace
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id/mcp-status", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { getMcpManager } = await import("../../mcp/client")
|
||||
const mcpManager = getMcpManager()
|
||||
|
||||
// Load config if not already loaded
|
||||
await mcpManager.loadConfig(workspace.path)
|
||||
|
||||
const status = mcpManager.getStatus()
|
||||
const tools = await mcpManager.getAllTools()
|
||||
|
||||
return {
|
||||
servers: status,
|
||||
toolCount: tools.length,
|
||||
tools: tools.map(t => ({ name: t.name, server: t.serverName, description: t.description }))
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to get MCP status")
|
||||
reply.code(500)
|
||||
return { error: "Failed to get MCP status" }
|
||||
}
|
||||
})
|
||||
|
||||
// Connect all configured MCPs for a workspace
|
||||
app.post<{ Params: { id: string } }>("/api/workspaces/:id/mcp-connect", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
try {
|
||||
const { getMcpManager } = await import("../../mcp/client")
|
||||
const mcpManager = getMcpManager()
|
||||
|
||||
// Load config first
|
||||
await mcpManager.loadConfig(workspace.path)
|
||||
|
||||
// Explicitly connect all servers
|
||||
const connectionResults = await mcpManager.connectAll()
|
||||
|
||||
// Get tools from connected servers
|
||||
const tools = await mcpManager.getAllTools()
|
||||
|
||||
// Transform connection results to status format
|
||||
const status: Record<string, { connected: boolean }> = {}
|
||||
for (const [name, result] of Object.entries(connectionResults)) {
|
||||
status[name] = { connected: result.connected }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
servers: status,
|
||||
toolCount: tools.length,
|
||||
connectionDetails: connectionResults
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to connect MCPs")
|
||||
reply.code(500)
|
||||
return { error: "Failed to connect MCPs" }
|
||||
}
|
||||
})
|
||||
|
||||
app.post<{
|
||||
Params: { id: string }
|
||||
Body: { name: string; description?: string; systemPrompt: string; mode?: string }
|
||||
}>("/api/workspaces/:id/agents", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
const { name, description, systemPrompt } = request.body
|
||||
if (!name || !systemPrompt) {
|
||||
reply.code(400)
|
||||
return { error: "Name and systemPrompt are required" }
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await deps.instanceStore.read(workspace.path)
|
||||
const customAgents = data.customAgents || []
|
||||
|
||||
// Update existing or add new
|
||||
const existingIndex = customAgents.findIndex(a => a.name === name)
|
||||
const agentData = { name, description, prompt: systemPrompt }
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
customAgents[existingIndex] = agentData
|
||||
} else {
|
||||
customAgents.push(agentData)
|
||||
}
|
||||
|
||||
await deps.instanceStore.write(workspace.path, {
|
||||
...data,
|
||||
customAgents
|
||||
})
|
||||
|
||||
return { success: true, agent: agentData }
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to save custom agent")
|
||||
reply.code(500)
|
||||
return { error: "Failed to save custom agent" }
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
// Serve static files from workspace for preview
|
||||
app.get<{ Params: { id: string; "*": string } }>("/api/workspaces/:id/serve/*", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
const relativePath = request.params["*"]
|
||||
const filePath = path.join(workspace.path, relativePath)
|
||||
|
||||
// Security check: ensure file is within workspace.path
|
||||
if (!filePath.startsWith(workspace.path)) {
|
||||
reply.code(403)
|
||||
return { error: "Access denied" }
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
reply.code(404)
|
||||
return { error: "File not found" }
|
||||
}
|
||||
|
||||
const stat = await readFileStat(filePath)
|
||||
if (!stat.isFile()) {
|
||||
reply.code(400)
|
||||
return { error: "Not a file" }
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
reply.type(mimeTypes[ext] || "application/octet-stream")
|
||||
return await readFile(filePath)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||
if (error instanceof Error && error.message === "Workspace not found") {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
|
||||
}
|
||||
367
packages/server/src/server/routes/zai.ts
Normal file
367
packages/server/src/server/routes/zai.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ZAIClient, ZAI_MODELS, type ZAIConfig, type ZAIChatRequest, type ZAIMessage } from "../../integrations/zai-api"
|
||||
import { Logger } from "../../logger"
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { getUserIntegrationsDir } from "../../user-data"
|
||||
import { CORE_TOOLS, executeTools, type ToolCall, type ToolResult } from "../../tools/executor"
|
||||
import { getMcpManager } from "../../mcp/client"
|
||||
|
||||
interface ZAIRouteDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const CONFIG_DIR = getUserIntegrationsDir()
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "zai-config.json")
|
||||
|
||||
// Maximum number of tool execution loops to prevent infinite recursion
|
||||
const MAX_TOOL_LOOPS = 10
|
||||
|
||||
export async function registerZAIRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: ZAIRouteDeps
|
||||
) {
|
||||
const logger = deps.logger.child({ component: "zai-routes" })
|
||||
|
||||
// Ensure config directory exists
|
||||
if (!existsSync(CONFIG_DIR)) {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
// Get Z.AI configuration
|
||||
app.get('/api/zai/config', async (request, reply) => {
|
||||
try {
|
||||
const config = getZAIConfig()
|
||||
return { config: { ...config, apiKey: config.apiKey ? '***' : undefined } }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get Z.AI config")
|
||||
return reply.status(500).send({ error: "Failed to get Z.AI configuration" })
|
||||
}
|
||||
})
|
||||
|
||||
// Update Z.AI configuration
|
||||
app.post('/api/zai/config', async (request, reply) => {
|
||||
try {
|
||||
const { enabled, apiKey, endpoint } = request.body as Partial<ZAIConfig>
|
||||
updateZAIConfig({ enabled, apiKey, endpoint })
|
||||
logger.info("Z.AI configuration updated")
|
||||
return { success: true, config: { enabled, endpoint, apiKey: apiKey ? '***' : undefined } }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to update Z.AI config")
|
||||
return reply.status(500).send({ error: "Failed to update Z.AI configuration" })
|
||||
}
|
||||
})
|
||||
|
||||
// Test Z.AI connection
|
||||
app.post('/api/zai/test', async (request, reply) => {
|
||||
try {
|
||||
const config = getZAIConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Z.AI is not enabled" })
|
||||
}
|
||||
|
||||
const client = new ZAIClient(config)
|
||||
const isConnected = await client.testConnection()
|
||||
|
||||
return { connected: isConnected }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Z.AI connection test failed")
|
||||
return reply.status(500).send({ error: "Connection test failed" })
|
||||
}
|
||||
})
|
||||
|
||||
// List available models
|
||||
app.get('/api/zai/models', async (request, reply) => {
|
||||
try {
|
||||
return { models: ZAI_MODELS.map(name => ({ name, provider: "zai" })) }
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to list Z.AI models")
|
||||
return reply.status(500).send({ error: "Failed to list models" })
|
||||
}
|
||||
})
|
||||
|
||||
// Chat completion endpoint WITH MCP TOOL SUPPORT
|
||||
app.post('/api/zai/chat', async (request, reply) => {
|
||||
try {
|
||||
const config = getZAIConfig()
|
||||
if (!config.enabled) {
|
||||
return reply.status(400).send({ error: "Z.AI is not enabled" })
|
||||
}
|
||||
|
||||
const client = new ZAIClient(config)
|
||||
const chatRequest = request.body as ZAIChatRequest & {
|
||||
workspacePath?: string
|
||||
enableTools?: boolean
|
||||
}
|
||||
|
||||
// Extract workspace path for tool execution
|
||||
// IMPORTANT: workspacePath must be provided by frontend, otherwise tools write to server directory
|
||||
const workspacePath = chatRequest.workspacePath || process.cwd()
|
||||
const enableTools = chatRequest.enableTools !== false // Default to true
|
||||
|
||||
logger.info({
|
||||
workspacePath,
|
||||
receivedWorkspacePath: chatRequest.workspacePath,
|
||||
enableTools
|
||||
}, "Z.AI chat request received")
|
||||
|
||||
// Load MCP tools from workspace config
|
||||
let allTools = [...CORE_TOOLS]
|
||||
if (enableTools && workspacePath) {
|
||||
try {
|
||||
const mcpManager = getMcpManager()
|
||||
await mcpManager.loadConfig(workspacePath)
|
||||
const mcpTools = await mcpManager.getToolsAsOpenAIFormat()
|
||||
allTools = [...CORE_TOOLS, ...mcpTools]
|
||||
if (mcpTools.length > 0) {
|
||||
logger.info({ mcpToolCount: mcpTools.length }, "Loaded MCP tools")
|
||||
}
|
||||
} catch (mcpError) {
|
||||
logger.warn({ error: mcpError }, "Failed to load MCP tools, using core tools only")
|
||||
}
|
||||
}
|
||||
|
||||
// Inject tools into request if enabled
|
||||
const requestWithTools: ZAIChatRequest = {
|
||||
...chatRequest,
|
||||
tools: enableTools ? allTools : undefined,
|
||||
tool_choice: enableTools ? "auto" : undefined
|
||||
}
|
||||
|
||||
// Handle streaming with tool execution loop
|
||||
if (chatRequest.stream) {
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
})
|
||||
|
||||
try {
|
||||
await streamWithToolLoop(
|
||||
client,
|
||||
requestWithTools,
|
||||
workspacePath,
|
||||
enableTools,
|
||||
reply.raw,
|
||||
logger
|
||||
)
|
||||
reply.raw.end()
|
||||
} catch (streamError) {
|
||||
logger.error({ error: streamError }, "Z.AI streaming failed")
|
||||
reply.raw.write(`data: ${JSON.stringify({ error: String(streamError) })}\n\n`)
|
||||
reply.raw.end()
|
||||
}
|
||||
} else {
|
||||
// Non-streaming with tool loop
|
||||
const response = await chatWithToolLoop(
|
||||
client,
|
||||
requestWithTools,
|
||||
workspacePath,
|
||||
enableTools,
|
||||
logger
|
||||
)
|
||||
return response
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Z.AI chat request failed")
|
||||
return reply.status(500).send({ error: "Chat request failed" })
|
||||
}
|
||||
})
|
||||
|
||||
logger.info("Z.AI routes registered with MCP tool support")
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat with tool execution loop
|
||||
*/
|
||||
async function streamWithToolLoop(
|
||||
client: ZAIClient,
|
||||
request: ZAIChatRequest,
|
||||
workspacePath: string,
|
||||
enableTools: boolean,
|
||||
rawResponse: any,
|
||||
logger: Logger
|
||||
): Promise<void> {
|
||||
let messages = [...request.messages]
|
||||
let loopCount = 0
|
||||
|
||||
while (loopCount < MAX_TOOL_LOOPS) {
|
||||
loopCount++
|
||||
|
||||
// Accumulate tool calls from stream
|
||||
let accumulatedToolCalls: { [index: number]: { id: string; name: string; arguments: string } } = {}
|
||||
let hasToolCalls = false
|
||||
let textContent = ""
|
||||
|
||||
// Stream response
|
||||
for await (const chunk of client.chatStream({ ...request, messages })) {
|
||||
// Write chunk to client
|
||||
rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
|
||||
const choice = chunk.choices[0]
|
||||
if (!choice) continue
|
||||
|
||||
// Accumulate text content
|
||||
if (choice.delta?.content) {
|
||||
textContent += choice.delta.content
|
||||
}
|
||||
|
||||
// Accumulate tool calls from delta
|
||||
if (choice.delta?.tool_calls) {
|
||||
hasToolCalls = true
|
||||
for (const tc of choice.delta.tool_calls) {
|
||||
const idx = tc.index ?? 0
|
||||
if (!accumulatedToolCalls[idx]) {
|
||||
accumulatedToolCalls[idx] = { id: tc.id || "", name: "", arguments: "" }
|
||||
}
|
||||
if (tc.id) accumulatedToolCalls[idx].id = tc.id
|
||||
if (tc.function?.name) accumulatedToolCalls[idx].name += tc.function.name
|
||||
if (tc.function?.arguments) accumulatedToolCalls[idx].arguments += tc.function.arguments
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should stop
|
||||
if (choice.finish_reason === "stop") {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no tool calls, we're done
|
||||
if (!hasToolCalls || !enableTools) {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
|
||||
// Convert accumulated tool calls
|
||||
const toolCalls: ToolCall[] = Object.values(accumulatedToolCalls).map(tc => ({
|
||||
id: tc.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments
|
||||
}
|
||||
}))
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info({ toolCalls: toolCalls.map(tc => tc.function.name) }, "Executing tool calls")
|
||||
|
||||
// Add assistant message with tool calls
|
||||
const assistantMessage: ZAIMessage = {
|
||||
role: "assistant",
|
||||
content: textContent || undefined,
|
||||
tool_calls: toolCalls
|
||||
}
|
||||
messages.push(assistantMessage)
|
||||
|
||||
// Execute tools
|
||||
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||
|
||||
// Notify client about tool execution via special event
|
||||
for (const result of toolResults) {
|
||||
const toolEvent = {
|
||||
type: "tool_result",
|
||||
tool_call_id: result.tool_call_id,
|
||||
content: result.content
|
||||
}
|
||||
rawResponse.write(`data: ${JSON.stringify(toolEvent)}\n\n`)
|
||||
}
|
||||
|
||||
// Add tool results to messages
|
||||
for (const result of toolResults) {
|
||||
const toolMessage: ZAIMessage = {
|
||||
role: "tool",
|
||||
content: result.content,
|
||||
tool_call_id: result.tool_call_id
|
||||
}
|
||||
messages.push(toolMessage)
|
||||
}
|
||||
|
||||
logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete")
|
||||
}
|
||||
|
||||
logger.warn({ loopCount }, "Max tool loops reached")
|
||||
rawResponse.write('data: [DONE]\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming chat with tool execution loop
|
||||
*/
|
||||
async function chatWithToolLoop(
|
||||
client: ZAIClient,
|
||||
request: ZAIChatRequest,
|
||||
workspacePath: string,
|
||||
enableTools: boolean,
|
||||
logger: Logger
|
||||
): Promise<any> {
|
||||
let messages = [...request.messages]
|
||||
let loopCount = 0
|
||||
let lastResponse: any = null
|
||||
|
||||
while (loopCount < MAX_TOOL_LOOPS) {
|
||||
loopCount++
|
||||
|
||||
const response = await client.chat({ ...request, messages, stream: false })
|
||||
lastResponse = response
|
||||
|
||||
const choice = response.choices[0]
|
||||
if (!choice) break
|
||||
|
||||
const toolCalls = choice.message?.tool_calls
|
||||
|
||||
// If no tool calls or finish_reason is "stop", return
|
||||
if (!toolCalls || toolCalls.length === 0 || !enableTools) {
|
||||
return response
|
||||
}
|
||||
|
||||
logger.info({ toolCalls: toolCalls.map((tc: any) => tc.function.name) }, "Executing tool calls")
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage: ZAIMessage = {
|
||||
role: "assistant",
|
||||
content: choice.message.content || undefined,
|
||||
tool_calls: toolCalls
|
||||
}
|
||||
messages.push(assistantMessage)
|
||||
|
||||
// Execute tools
|
||||
const toolResults = await executeTools(workspacePath, toolCalls)
|
||||
|
||||
// Add tool results
|
||||
for (const result of toolResults) {
|
||||
const toolMessage: ZAIMessage = {
|
||||
role: "tool",
|
||||
content: result.content,
|
||||
tool_call_id: result.tool_call_id
|
||||
}
|
||||
messages.push(toolMessage)
|
||||
}
|
||||
|
||||
logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete")
|
||||
}
|
||||
|
||||
logger.warn({ loopCount }, "Max tool loops reached")
|
||||
return lastResponse
|
||||
}
|
||||
|
||||
function getZAIConfig(): ZAIConfig {
|
||||
try {
|
||||
if (existsSync(CONFIG_FILE)) {
|
||||
const data = readFileSync(CONFIG_FILE, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
}
|
||||
return { enabled: false, endpoint: "https://api.z.ai/api/coding/paas/v4", timeout: 300000 }
|
||||
} catch {
|
||||
return { enabled: false, endpoint: "https://api.z.ai/api/coding/paas/v4", timeout: 300000 }
|
||||
}
|
||||
}
|
||||
|
||||
function updateZAIConfig(config: Partial<ZAIConfig>): void {
|
||||
const current = getZAIConfig()
|
||||
const updated = { ...current, ...config }
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2))
|
||||
}
|
||||
65
packages/server/src/storage/instance-store.ts
Normal file
65
packages/server/src/storage/instance-store.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import path from "path"
|
||||
import type { InstanceData } from "../api-types"
|
||||
import { getUserInstancesDir } from "../user-data"
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
agentModelSelections: {},
|
||||
sessionTasks: {},
|
||||
}
|
||||
|
||||
export class InstanceStore {
|
||||
private readonly instancesDir: string
|
||||
|
||||
constructor(baseDir = getUserInstancesDir()) {
|
||||
this.instancesDir = baseDir
|
||||
fs.mkdirSync(this.instancesDir, { recursive: true })
|
||||
}
|
||||
|
||||
async read(id: string): Promise<InstanceData> {
|
||||
try {
|
||||
const filePath = this.resolvePath(id)
|
||||
const content = await fsp.readFile(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
return { ...DEFAULT_INSTANCE_DATA, ...parsed }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return DEFAULT_INSTANCE_DATA
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async write(id: string, data: InstanceData): Promise<void> {
|
||||
const filePath = this.resolvePath(id)
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fsp.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8")
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
const filePath = this.resolvePath(id)
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePath(id: string): string {
|
||||
const filename = this.sanitizeId(id)
|
||||
return path.join(this.instancesDir, `${filename}.json`)
|
||||
}
|
||||
|
||||
private sanitizeId(id: string): string {
|
||||
return id
|
||||
.replace(/[\\/]/g, "_")
|
||||
.replace(/[^a-zA-Z0-9_.-]/g, "_")
|
||||
.replace(/_{2,}/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.toLowerCase()
|
||||
}
|
||||
}
|
||||
284
packages/server/src/storage/session-store.ts
Normal file
284
packages/server/src/storage/session-store.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Session Store - Native session management without OpenCode binary
|
||||
*
|
||||
* This provides a complete replacement for OpenCode's session management,
|
||||
* allowing NomadArch to work in "Binary-Free Mode".
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from "fs/promises"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { ulid } from "ulid"
|
||||
import { createLogger } from "../logger"
|
||||
|
||||
const log = createLogger({ component: "session-store" })
|
||||
|
||||
// Types matching OpenCode's schema for compatibility
|
||||
export interface SessionMessage {
|
||||
id: string
|
||||
sessionId: string
|
||||
role: "user" | "assistant" | "system" | "tool"
|
||||
content?: string
|
||||
parts?: MessagePart[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
toolCalls?: ToolCall[]
|
||||
toolCallId?: string
|
||||
status?: "pending" | "streaming" | "completed" | "error"
|
||||
}
|
||||
|
||||
export interface MessagePart {
|
||||
type: "text" | "tool_call" | "tool_result" | "thinking" | "code"
|
||||
content?: string
|
||||
toolCall?: ToolCall
|
||||
toolResult?: ToolResult
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
toolCallId: string
|
||||
content: string
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
workspaceId: string
|
||||
title?: string
|
||||
parentId?: string | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
messageIds: string[]
|
||||
model?: {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
agent?: string
|
||||
revert?: {
|
||||
messageID: string
|
||||
reason?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
sessions: Record<string, Session>
|
||||
messages: Record<string, SessionMessage>
|
||||
}
|
||||
|
||||
/**
|
||||
* Native session management for Binary-Free Mode
|
||||
*/
|
||||
export class NativeSessionManager {
|
||||
private stores = new Map<string, SessionStore>()
|
||||
private dataDir: string
|
||||
|
||||
constructor(dataDir: string) {
|
||||
this.dataDir = dataDir
|
||||
}
|
||||
|
||||
private getStorePath(workspaceId: string): string {
|
||||
return path.join(this.dataDir, workspaceId, "sessions.json")
|
||||
}
|
||||
|
||||
private async ensureDir(workspaceId: string): Promise<void> {
|
||||
const dir = path.join(this.dataDir, workspaceId)
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStore(workspaceId: string): Promise<SessionStore> {
|
||||
if (this.stores.has(workspaceId)) {
|
||||
return this.stores.get(workspaceId)!
|
||||
}
|
||||
|
||||
const storePath = this.getStorePath(workspaceId)
|
||||
let store: SessionStore = { sessions: {}, messages: {} }
|
||||
|
||||
if (existsSync(storePath)) {
|
||||
try {
|
||||
const data = await readFile(storePath, "utf-8")
|
||||
store = JSON.parse(data)
|
||||
} catch (error) {
|
||||
log.error({ workspaceId, error }, "Failed to load session store")
|
||||
}
|
||||
}
|
||||
|
||||
this.stores.set(workspaceId, store)
|
||||
return store
|
||||
}
|
||||
|
||||
private async saveStore(workspaceId: string): Promise<void> {
|
||||
const store = this.stores.get(workspaceId)
|
||||
if (!store) return
|
||||
|
||||
await this.ensureDir(workspaceId)
|
||||
const storePath = this.getStorePath(workspaceId)
|
||||
await writeFile(storePath, JSON.stringify(store, null, 2), "utf-8")
|
||||
}
|
||||
|
||||
// Session CRUD operations
|
||||
|
||||
async listSessions(workspaceId: string): Promise<Session[]> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
return Object.values(store.sessions).sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
async getSession(workspaceId: string, sessionId: string): Promise<Session | null> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
return store.sessions[sessionId] ?? null
|
||||
}
|
||||
|
||||
async createSession(workspaceId: string, options?: {
|
||||
title?: string
|
||||
parentId?: string
|
||||
model?: { providerId: string; modelId: string }
|
||||
agent?: string
|
||||
}): Promise<Session> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
const now = Date.now()
|
||||
|
||||
const session: Session = {
|
||||
id: ulid(),
|
||||
workspaceId,
|
||||
title: options?.title ?? "New Session",
|
||||
parentId: options?.parentId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messageIds: [],
|
||||
model: options?.model,
|
||||
agent: options?.agent,
|
||||
}
|
||||
|
||||
store.sessions[session.id] = session
|
||||
await this.saveStore(workspaceId)
|
||||
|
||||
log.info({ workspaceId, sessionId: session.id }, "Created new session")
|
||||
return session
|
||||
}
|
||||
|
||||
async updateSession(workspaceId: string, sessionId: string, updates: Partial<Session>): Promise<Session | null> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
const session = store.sessions[sessionId]
|
||||
if (!session) return null
|
||||
|
||||
const updated = {
|
||||
...session,
|
||||
...updates,
|
||||
id: session.id, // Prevent ID change
|
||||
workspaceId: session.workspaceId, // Prevent workspace change
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
store.sessions[sessionId] = updated
|
||||
await this.saveStore(workspaceId)
|
||||
return updated
|
||||
}
|
||||
|
||||
async deleteSession(workspaceId: string, sessionId: string): Promise<boolean> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
const session = store.sessions[sessionId]
|
||||
if (!session) return false
|
||||
|
||||
// Delete all messages in the session
|
||||
for (const messageId of session.messageIds) {
|
||||
delete store.messages[messageId]
|
||||
}
|
||||
|
||||
delete store.sessions[sessionId]
|
||||
await this.saveStore(workspaceId)
|
||||
|
||||
log.info({ workspaceId, sessionId }, "Deleted session")
|
||||
return true
|
||||
}
|
||||
|
||||
// Message operations
|
||||
|
||||
async getSessionMessages(workspaceId: string, sessionId: string): Promise<SessionMessage[]> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
const session = store.sessions[sessionId]
|
||||
if (!session) return []
|
||||
|
||||
return session.messageIds
|
||||
.map(id => store.messages[id])
|
||||
.filter((msg): msg is SessionMessage => msg !== undefined)
|
||||
}
|
||||
|
||||
async addMessage(workspaceId: string, sessionId: string, message: Omit<SessionMessage, "id" | "sessionId" | "createdAt" | "updatedAt">): Promise<SessionMessage> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
const session = store.sessions[sessionId]
|
||||
if (!session) throw new Error(`Session not found: ${sessionId}`)
|
||||
|
||||
const now = Date.now()
|
||||
const newMessage: SessionMessage = {
|
||||
...message,
|
||||
id: ulid(),
|
||||
sessionId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
store.messages[newMessage.id] = newMessage
|
||||
session.messageIds.push(newMessage.id)
|
||||
session.updatedAt = now
|
||||
|
||||
await this.saveStore(workspaceId)
|
||||
return newMessage
|
||||
}
|
||||
|
||||
async updateMessage(workspaceId: string, messageId: string, updates: Partial<SessionMessage>): Promise<SessionMessage | null> {
|
||||
const store = await this.loadStore(workspaceId)
|
||||
const message = store.messages[messageId]
|
||||
if (!message) return null
|
||||
|
||||
const updated = {
|
||||
...message,
|
||||
...updates,
|
||||
id: message.id, // Prevent ID change
|
||||
sessionId: message.sessionId, // Prevent session change
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
store.messages[messageId] = updated
|
||||
await this.saveStore(workspaceId)
|
||||
return updated
|
||||
}
|
||||
|
||||
// Utility
|
||||
|
||||
async clearWorkspace(workspaceId: string): Promise<void> {
|
||||
this.stores.delete(workspaceId)
|
||||
// Optionally delete file
|
||||
}
|
||||
|
||||
getActiveSessionCount(workspaceId: string): number {
|
||||
const store = this.stores.get(workspaceId)
|
||||
return store ? Object.keys(store.sessions).length : 0
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let sessionManager: NativeSessionManager | null = null
|
||||
|
||||
export function getSessionManager(dataDir?: string): NativeSessionManager {
|
||||
if (!sessionManager) {
|
||||
if (!dataDir) {
|
||||
throw new Error("Session manager not initialized - provide dataDir")
|
||||
}
|
||||
sessionManager = new NativeSessionManager(dataDir)
|
||||
}
|
||||
return sessionManager
|
||||
}
|
||||
|
||||
export function initSessionManager(dataDir: string): NativeSessionManager {
|
||||
sessionManager = new NativeSessionManager(dataDir)
|
||||
return sessionManager
|
||||
}
|
||||
352
packages/server/src/tools/executor.ts
Normal file
352
packages/server/src/tools/executor.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Tool Executor Service
|
||||
* Provides MCP-compatible tool definitions and execution for all AI models.
|
||||
* This enables Z.AI, Qwen, OpenCode Zen, etc. to write files, read files, and interact with the workspace.
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { createLogger } from "../logger"
|
||||
import { getMcpManager } from "../mcp/client"
|
||||
|
||||
const log = createLogger({ component: "tool-executor" })
|
||||
|
||||
// OpenAI-compatible Tool Definition Schema
|
||||
export interface ToolDefinition {
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
description: string
|
||||
parameters: {
|
||||
type: "object"
|
||||
properties: Record<string, { type: string; description?: string }>
|
||||
required?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool Call from LLM Response
|
||||
export interface ToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string // JSON string
|
||||
}
|
||||
}
|
||||
|
||||
// Tool Execution Result
|
||||
export interface ToolResult {
|
||||
tool_call_id: string
|
||||
role: "tool"
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Tool Definitions for MCP
|
||||
* These follow OpenAI's function calling schema (compatible with Z.AI GLM-4)
|
||||
*/
|
||||
export const CORE_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "write_file",
|
||||
description: "Write content to a file in the workspace. Creates the file if it doesn't exist, or overwrites if it does. Use this to generate code files, configuration, or any text content.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative path to the file within the workspace (e.g., 'src/components/Button.tsx')"
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "The full content to write to the file"
|
||||
}
|
||||
},
|
||||
required: ["path", "content"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read the contents of a file from the workspace.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative path to the file within the workspace"
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_files",
|
||||
description: "List files and directories in a workspace directory.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative path to the directory (use '.' for root)"
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_directory",
|
||||
description: "Create a directory in the workspace. Creates parent directories if needed.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative path to the directory to create"
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_file",
|
||||
description: "Delete a file from the workspace.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Relative path to the file to delete"
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Execute a tool call within a workspace context
|
||||
*/
|
||||
export async function executeTool(
|
||||
workspacePath: string,
|
||||
toolCall: ToolCall
|
||||
): Promise<ToolResult> {
|
||||
const { id, function: fn } = toolCall
|
||||
const name = fn.name
|
||||
let args: Record<string, unknown>
|
||||
|
||||
try {
|
||||
args = JSON.parse(fn.arguments)
|
||||
} catch (e) {
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: Failed to parse tool arguments: ${fn.arguments}`
|
||||
}
|
||||
}
|
||||
|
||||
log.info({ tool: name, args, workspacePath }, "Executing tool")
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case "write_file": {
|
||||
const relativePath = String(args.path || "")
|
||||
const content = String(args.content || "")
|
||||
const fullPath = path.resolve(workspacePath, relativePath)
|
||||
|
||||
// Security check: ensure we're still within workspace
|
||||
if (!fullPath.startsWith(path.resolve(workspacePath))) {
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: Path escapes workspace boundary: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = path.dirname(fullPath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
fs.writeFileSync(fullPath, content, "utf-8")
|
||||
log.info({ path: relativePath, bytes: content.length }, "File written successfully")
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Successfully wrote ${content.length} bytes to ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
case "read_file": {
|
||||
const relativePath = String(args.path || "")
|
||||
const fullPath = path.resolve(workspacePath, relativePath)
|
||||
|
||||
if (!fullPath.startsWith(path.resolve(workspacePath))) {
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: Path escapes workspace boundary: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: File not found: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, "utf-8")
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: content.slice(0, 50000) // Limit to prevent context overflow
|
||||
}
|
||||
}
|
||||
|
||||
case "list_files": {
|
||||
const relativePath = String(args.path || ".")
|
||||
const fullPath = path.resolve(workspacePath, relativePath)
|
||||
|
||||
if (!fullPath.startsWith(path.resolve(workspacePath))) {
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: Path escapes workspace boundary: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: Directory not found: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(fullPath, { withFileTypes: true })
|
||||
const listing = entries.map(e =>
|
||||
e.isDirectory() ? `${e.name}/` : e.name
|
||||
).join("\n")
|
||||
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: listing || "(empty directory)"
|
||||
}
|
||||
}
|
||||
|
||||
case "create_directory": {
|
||||
const relativePath = String(args.path || "")
|
||||
const fullPath = path.resolve(workspacePath, relativePath)
|
||||
|
||||
if (!fullPath.startsWith(path.resolve(workspacePath))) {
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: Path escapes workspace boundary: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(fullPath, { recursive: true })
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Successfully created directory: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
case "delete_file": {
|
||||
const relativePath = String(args.path || "")
|
||||
const fullPath = path.resolve(workspacePath, relativePath)
|
||||
|
||||
if (!fullPath.startsWith(path.resolve(workspacePath))) {
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: Path escapes workspace boundary: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: File not found: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
fs.unlinkSync(fullPath)
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Successfully deleted: ${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
// Check if this is an MCP tool (format: mcp_servername_toolname)
|
||||
if (name.startsWith("mcp_")) {
|
||||
try {
|
||||
const mcpManager = getMcpManager()
|
||||
const result = await mcpManager.executeTool(name, args)
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: result
|
||||
}
|
||||
} catch (mcpError) {
|
||||
const message = mcpError instanceof Error ? mcpError.message : String(mcpError)
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `MCP tool error: ${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error: Unknown tool: ${name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
log.error({ tool: name, error: message }, "Tool execution failed")
|
||||
return {
|
||||
tool_call_id: id,
|
||||
role: "tool",
|
||||
content: `Error executing ${name}: ${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple tool calls in parallel
|
||||
*/
|
||||
export async function executeTools(
|
||||
workspacePath: string,
|
||||
toolCalls: ToolCall[]
|
||||
): Promise<ToolResult[]> {
|
||||
return Promise.all(
|
||||
toolCalls.map(tc => executeTool(workspacePath, tc))
|
||||
)
|
||||
}
|
||||
13
packages/server/src/tools/index.ts
Normal file
13
packages/server/src/tools/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Tools Module Index
|
||||
* Exports MCP-compatible tool definitions and executor for AI agent integration.
|
||||
*/
|
||||
|
||||
export {
|
||||
CORE_TOOLS,
|
||||
executeTool,
|
||||
executeTools,
|
||||
type ToolDefinition,
|
||||
type ToolCall,
|
||||
type ToolResult
|
||||
} from "./executor"
|
||||
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)
|
||||
}
|
||||
195
packages/server/src/workspaces/instance-events.ts
Normal file
195
packages/server/src/workspaces/instance-events.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Agent, fetch } from "undici"
|
||||
import { Agent as UndiciAgent } from "undici"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "./manager"
|
||||
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
|
||||
|
||||
const INSTANCE_HOST = "127.0.0.1"
|
||||
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
|
||||
const RECONNECT_DELAY_MS = 1000
|
||||
|
||||
interface InstanceEventBridgeOptions {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface ActiveStream {
|
||||
controller: AbortController
|
||||
task: Promise<void>
|
||||
}
|
||||
|
||||
export class InstanceEventBridge {
|
||||
private readonly streams = new Map<string, ActiveStream>()
|
||||
|
||||
constructor(private readonly options: InstanceEventBridgeOptions) {
|
||||
const bus = this.options.eventBus
|
||||
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
|
||||
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
|
||||
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const [id, active] of this.streams) {
|
||||
active.controller.abort()
|
||||
this.publishStatus(id, "disconnected")
|
||||
}
|
||||
this.streams.clear()
|
||||
}
|
||||
|
||||
private startStream(workspaceId: string) {
|
||||
if (this.streams.has(workspaceId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const task = this.runStream(workspaceId, controller.signal)
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
|
||||
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
const active = this.streams.get(workspaceId)
|
||||
if (active?.controller === controller) {
|
||||
this.streams.delete(workspaceId)
|
||||
}
|
||||
})
|
||||
|
||||
this.streams.set(workspaceId, { controller, task })
|
||||
}
|
||||
|
||||
private stopStream(workspaceId: string, reason?: string) {
|
||||
const active = this.streams.get(workspaceId)
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
active.controller.abort()
|
||||
this.streams.delete(workspaceId)
|
||||
this.publishStatus(workspaceId, "disconnected", reason)
|
||||
}
|
||||
|
||||
private async runStream(workspaceId: string, signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
const port = this.options.workspaceManager.getInstancePort(workspaceId)
|
||||
if (!port) {
|
||||
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||
continue
|
||||
}
|
||||
|
||||
this.publishStatus(workspaceId, "connecting")
|
||||
|
||||
try {
|
||||
await this.consumeStream(workspaceId, port, signal)
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
|
||||
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal,
|
||||
dispatcher: STREAM_AGENT,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Instance event stream unavailable (${response.status})`)
|
||||
}
|
||||
|
||||
this.publishStatus(workspaceId, "connected")
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || !value) {
|
||||
break
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
buffer = this.flushEvents(buffer, workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
private flushEvents(buffer: string, workspaceId: string) {
|
||||
let separatorIndex = buffer.indexOf("\n\n")
|
||||
|
||||
while (separatorIndex >= 0) {
|
||||
const chunk = buffer.slice(0, separatorIndex)
|
||||
buffer = buffer.slice(separatorIndex + 2)
|
||||
this.processChunk(chunk, workspaceId)
|
||||
separatorIndex = buffer.indexOf("\n\n")
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
private processChunk(chunk: string, workspaceId: string) {
|
||||
const lines = chunk.split(/\r?\n/)
|
||||
const dataLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(":")) {
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart())
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = dataLines.join("\n").trim()
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
||||
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
||||
if (this.options.logger.isLevelEnabled("trace")) {
|
||||
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||
}
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
|
||||
}
|
||||
}
|
||||
|
||||
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
|
||||
this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated")
|
||||
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
|
||||
}
|
||||
|
||||
private delay(duration: number, signal: AbortSignal) {
|
||||
if (duration <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, duration)
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
481
packages/server/src/workspaces/manager.ts
Normal file
481
packages/server/src/workspaces/manager.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { connect } from "net"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
import { ensureWorkspaceOpencodeConfig } from "../opencode-config"
|
||||
import { getContextEngineService } from "../context-engine"
|
||||
|
||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor { }
|
||||
|
||||
export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
private readonly runtime: WorkspaceRuntime
|
||||
|
||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||
}
|
||||
|
||||
list(): WorkspaceDescriptor[] {
|
||||
return Array.from(this.workspaces.values())
|
||||
}
|
||||
|
||||
get(id: string): WorkspaceDescriptor | undefined {
|
||||
return this.workspaces.get(id)
|
||||
}
|
||||
|
||||
getInstancePort(id: string): number | undefined {
|
||||
return this.workspaces.get(id)?.port
|
||||
}
|
||||
|
||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
return browser.list(relativePath)
|
||||
}
|
||||
|
||||
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
return searchWorkspaceFiles(workspace.path, query, options)
|
||||
}
|
||||
|
||||
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
const contents = browser.readFile(relativePath)
|
||||
return {
|
||||
workspaceId,
|
||||
relativePath,
|
||||
contents,
|
||||
}
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
const binary = this.options.binaryRegistry.resolveDefault()
|
||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
clearWorkspaceSearchCache(workspacePath)
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
id,
|
||||
path: workspacePath,
|
||||
name,
|
||||
status: "starting",
|
||||
proxyPath,
|
||||
binaryId: resolvedBinaryPath,
|
||||
binaryLabel: binary.label,
|
||||
binaryVersion: binary.version,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
const opencodeConfigDir = ensureWorkspaceOpencodeConfig(id)
|
||||
const environment = {
|
||||
...userEnvironment,
|
||||
OPENCODE_CONFIG_DIR: opencodeConfigDir,
|
||||
}
|
||||
|
||||
try {
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
environment,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
descriptor.status = "ready"
|
||||
descriptor.updatedAt = new Date().toISOString()
|
||||
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
||||
this.options.logger.info({ workspaceId: id, port }, "Workspace ready")
|
||||
|
||||
// Trigger Context-Engine indexing (non-blocking)
|
||||
const contextEngine = getContextEngineService()
|
||||
if (contextEngine) {
|
||||
contextEngine.indexPath(workspacePath).catch((error) => {
|
||||
this.options.logger.warn({ workspaceId: id, error }, "Context-Engine indexing failed")
|
||||
})
|
||||
}
|
||||
|
||||
return descriptor
|
||||
} catch (error) {
|
||||
descriptor.status = "error"
|
||||
let errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// Check for common OpenCode issues
|
||||
if (errorMessage.includes('ENOENT') || errorMessage.includes('command not found')) {
|
||||
errorMessage = `OpenCode binary not found at '${resolvedBinaryPath}'. Please install OpenCode CLI from https://opencode.ai/ and ensure it's in your PATH.`
|
||||
} else if (errorMessage.includes('health check')) {
|
||||
errorMessage = `Workspace health check failed. OpenCode started but is not responding correctly. Check OpenCode logs for details.`
|
||||
}
|
||||
|
||||
descriptor.error = errorMessage
|
||||
descriptor.updatedAt = new Date().toISOString()
|
||||
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<WorkspaceDescriptor | undefined> {
|
||||
const workspace = this.workspaces.get(id)
|
||||
if (!workspace) return undefined
|
||||
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace")
|
||||
const wasRunning = Boolean(workspace.pid)
|
||||
if (wasRunning) {
|
||||
await this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly")
|
||||
})
|
||||
}
|
||||
|
||||
this.workspaces.delete(id)
|
||||
clearWorkspaceSearchCache(workspace.path)
|
||||
if (!wasRunning) {
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.options.logger.info("Shutting down all workspaces")
|
||||
for (const [id, workspace] of this.workspaces) {
|
||||
if (workspace.pid) {
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
await this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
})
|
||||
} else {
|
||||
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||
}
|
||||
}
|
||||
this.workspaces.clear()
|
||||
this.options.logger.info("All workspaces cleared")
|
||||
}
|
||||
|
||||
private requireWorkspace(id: string): WorkspaceRecord {
|
||||
const workspace = this.workspaces.get(id)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
private resolveBinaryPath(identifier: string): string {
|
||||
if (!identifier) {
|
||||
return identifier
|
||||
}
|
||||
|
||||
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
|
||||
if (path.isAbsolute(identifier) || looksLikePath) {
|
||||
return identifier
|
||||
}
|
||||
|
||||
const locator = process.platform === "win32" ? "where" : "which"
|
||||
|
||||
try {
|
||||
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const resolved = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
|
||||
if (resolved) {
|
||||
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
||||
return resolved
|
||||
}
|
||||
} else if (result.error) {
|
||||
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
||||
}
|
||||
|
||||
return identifier
|
||||
}
|
||||
|
||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||
if (!resolvedPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
|
||||
if (line) {
|
||||
const normalized = line.trim()
|
||||
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||
if (versionMatch) {
|
||||
const version = versionMatch[1]
|
||||
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
|
||||
return version
|
||||
}
|
||||
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
|
||||
return normalized
|
||||
}
|
||||
} else if (result.error) {
|
||||
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async waitForWorkspaceReadiness(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
|
||||
await Promise.race([
|
||||
this.waitForPortAvailability(params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited before becoming ready",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited shortly after start",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
const probeResult = await Promise.race([
|
||||
this.probeInstance(params.workspaceId, params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited during health checks",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : ""
|
||||
const reason = probeResult.reason ?? "Health check failed"
|
||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`)
|
||||
}
|
||||
|
||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||
// Try multiple possible health check endpoints
|
||||
const endpoints = [
|
||||
`/project/current`,
|
||||
`/health`,
|
||||
`/status`,
|
||||
`/`,
|
||||
`/api/health`
|
||||
]
|
||||
|
||||
this.options.logger.info({ workspaceId, port, endpoints }, "Starting health check probe")
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const url = `http://127.0.0.1:${port}${endpoint}`
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'NomadArch-HealthCheck/1.0'
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe response received")
|
||||
|
||||
if (response.ok) {
|
||||
this.options.logger.info({ workspaceId, port, endpoint }, "Health check passed")
|
||||
return { ok: true }
|
||||
} else {
|
||||
this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe endpoint returned error")
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger.debug({ workspaceId, port, err: error, url, endpoint }, "Health probe endpoint failed")
|
||||
// Continue to next endpoint
|
||||
}
|
||||
}
|
||||
|
||||
// All endpoints failed
|
||||
const reason = `OpenCode server started but is not responding to any known health endpoints (/project/current, /health, /status, /, /api/health)`
|
||||
this.options.logger.error({ workspaceId, port }, "All health check endpoints failed")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
|
||||
private buildStartupError(
|
||||
workspaceId: string,
|
||||
phase: string,
|
||||
exitInfo: ProcessExitInfo,
|
||||
lastOutput: string,
|
||||
): Error {
|
||||
const exitDetails = this.describeExit(exitInfo)
|
||||
const trimmedOutput = lastOutput.trim()
|
||||
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
|
||||
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
|
||||
}
|
||||
|
||||
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
|
||||
this.options.logger.info({ port, timeoutMs }, "Waiting for port availability - STARTING")
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let settled = false
|
||||
let retryTimer: NodeJS.Timeout | null = null
|
||||
let attemptCount = 0
|
||||
|
||||
const cleanup = () => {
|
||||
settled = true
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const tryConnect = () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
attemptCount++
|
||||
this.options.logger.debug({ port, attempt: attemptCount, timeRemaining: Math.max(0, deadline - Date.now()) }, "Attempting to connect to workspace port")
|
||||
|
||||
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||
this.options.logger.info({ port, attempt: attemptCount }, "Port is available - SUCCESS")
|
||||
cleanup()
|
||||
socket.end()
|
||||
resolve()
|
||||
})
|
||||
socket.once("error", (error) => {
|
||||
this.options.logger.debug({ port, attempt: attemptCount, err: error instanceof Error ? error.message : String(error) }, "Port connection failed - retrying")
|
||||
socket.destroy()
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
this.options.logger.error({ port, attempt: attemptCount, timeoutMs }, "Port did not become available - TIMEOUT")
|
||||
cleanup()
|
||||
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
|
||||
} else {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null
|
||||
tryConnect()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tryConnect()
|
||||
})
|
||||
}
|
||||
|
||||
private delay(durationMs: number): Promise<void> {
|
||||
if (durationMs <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, durationMs))
|
||||
}
|
||||
|
||||
private describeExit(info: ProcessExitInfo): string {
|
||||
if (info.signal) {
|
||||
return `signal ${info.signal}`
|
||||
}
|
||||
if (info.code !== null) {
|
||||
return `code ${info.code}`
|
||||
}
|
||||
return "unknown reason"
|
||||
}
|
||||
|
||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||
const workspace = this.workspaces.get(workspaceId)
|
||||
if (!workspace) return
|
||||
|
||||
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
||||
|
||||
workspace.pid = undefined
|
||||
workspace.port = undefined
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
|
||||
if (info.requested || info.code === 0) {
|
||||
workspace.status = "stopped"
|
||||
workspace.error = undefined
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
|
||||
} else {
|
||||
workspace.status = "error"
|
||||
workspace.error = `Process exited with code ${info.code}`
|
||||
this.options.eventBus.publish({ type: "workspace.error", workspace })
|
||||
}
|
||||
}
|
||||
}
|
||||
294
packages/server/src/workspaces/runtime.ts
Normal file
294
packages/server/src/workspaces/runtime.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { ChildProcess, spawn } from "child_process"
|
||||
import { existsSync, statSync, accessSync, constants } from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
interface LaunchOptions {
|
||||
workspaceId: string
|
||||
folder: string
|
||||
binaryPath: string
|
||||
environment?: Record<string, string>
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
export interface ProcessExitInfo {
|
||||
workspaceId: string
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
requested: boolean
|
||||
}
|
||||
|
||||
interface ManagedProcess {
|
||||
child: ChildProcess
|
||||
requestedStop: boolean
|
||||
}
|
||||
|
||||
export class WorkspaceRuntime {
|
||||
private processes = new Map<string, ManagedProcess>()
|
||||
|
||||
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) { }
|
||||
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
// Check if binary exists before attempting to launch
|
||||
try {
|
||||
accessSync(options.binaryPath, constants.F_OK)
|
||||
} catch (error) {
|
||||
throw new Error(`OpenCode binary not found: ${options.binaryPath}. Please install OpenCode CLI from https://opencode.ai/ and ensure it's in your PATH.`)
|
||||
}
|
||||
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
const env = {
|
||||
...process.env,
|
||||
...(options.environment ?? {}),
|
||||
"OPENCODE_SERVER_HOST": "127.0.0.1",
|
||||
"OPENCODE_SERVER_PORT": "0",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
{ workspaceId: options.workspaceId, binaryPath: options.binaryPath, args },
|
||||
"Starting OpenCode with arguments"
|
||||
)
|
||||
|
||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
|
||||
exitResolve = resolveExit
|
||||
})
|
||||
|
||||
// Store recent output for debugging - keep last 20 lines from each stream
|
||||
const MAX_OUTPUT_LINES = 20
|
||||
const recentStdout: string[] = []
|
||||
const recentStderr: string[] = []
|
||||
const getLastOutput = () => {
|
||||
const combined: string[] = []
|
||||
if (recentStderr.length > 0) {
|
||||
combined.push("=== STDERR ===")
|
||||
combined.push(...recentStderr.slice(-10))
|
||||
}
|
||||
if (recentStdout.length > 0) {
|
||||
combined.push("=== STDOUT ===")
|
||||
combined.push(...recentStdout.slice(-10))
|
||||
}
|
||||
return combined.join("\n")
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.logger.info(
|
||||
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
const child = spawn(options.binaryPath, args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
const managed: ManagedProcess = { child, requestedStop: false }
|
||||
this.processes.set(options.workspaceId, managed)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
let stderrBuffer = ""
|
||||
let portFound = false
|
||||
|
||||
let warningTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const startWarningTimer = () => {
|
||||
warningTimer = setInterval(() => {
|
||||
this.logger.warn({ workspaceId: options.workspaceId }, "Workspace runtime has not reported a port yet")
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const stopWarningTimer = () => {
|
||||
if (warningTimer) {
|
||||
clearInterval(warningTimer)
|
||||
warningTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
startWarningTimer()
|
||||
|
||||
const cleanupStreams = () => {
|
||||
stopWarningTimer()
|
||||
child.stdout?.removeAllListeners()
|
||||
child.stderr?.removeAllListeners()
|
||||
}
|
||||
|
||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited")
|
||||
this.processes.delete(options.workspaceId)
|
||||
cleanupStreams()
|
||||
child.removeListener("error", handleError)
|
||||
child.removeListener("exit", handleExit)
|
||||
const exitInfo: ProcessExitInfo = {
|
||||
workspaceId: options.workspaceId,
|
||||
code,
|
||||
signal,
|
||||
requested: managed.requestedStop,
|
||||
}
|
||||
if (exitResolve) {
|
||||
exitResolve(exitInfo)
|
||||
exitResolve = null
|
||||
}
|
||||
if (!portFound) {
|
||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(reason))
|
||||
} else {
|
||||
options.onExit?.(exitInfo)
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
cleanupStreams()
|
||||
child.removeListener("exit", handleExit)
|
||||
this.processes.delete(options.workspaceId)
|
||||
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
||||
if (exitResolve) {
|
||||
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
|
||||
exitResolve = null
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
|
||||
child.on("error", handleError)
|
||||
child.on("exit", handleExit)
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stdoutBuffer += text
|
||||
const lines = stdoutBuffer.split("\n")
|
||||
stdoutBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
// Store in recent buffer for debugging
|
||||
recentStdout.push(trimmed)
|
||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||
recentStdout.shift()
|
||||
}
|
||||
|
||||
this.emitLog(options.workspaceId, "info", line)
|
||||
|
||||
if (!portFound) {
|
||||
this.logger.debug({ workspaceId: options.workspaceId, line: trimmed }, "OpenCode output line")
|
||||
// Try multiple patterns for port detection
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) ||
|
||||
line.match(/server listening on http:\/\/.+:(\d+)/i) ||
|
||||
line.match(/listening on http:\/\/.+:(\d+)/i) ||
|
||||
line.match(/:(\d+)/i)
|
||||
|
||||
if (portMatch) {
|
||||
portFound = true
|
||||
child.removeListener("error", handleError)
|
||||
const port = parseInt(portMatch[1], 10)
|
||||
this.logger.info({ workspaceId: options.workspaceId, port, matchedLine: trimmed }, "Workspace runtime allocated port - PORT DETECTED")
|
||||
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
|
||||
} else {
|
||||
this.logger.debug({ workspaceId: options.workspaceId, line: trimmed }, "Port detection - no match in this line")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stderrBuffer += text
|
||||
const lines = stderrBuffer.split("\n")
|
||||
stderrBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
// Store in recent buffer for debugging
|
||||
recentStderr.push(trimmed)
|
||||
if (recentStderr.length > MAX_OUTPUT_LINES) {
|
||||
recentStderr.shift()
|
||||
}
|
||||
|
||||
this.emitLog(options.workspaceId, "error", line)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async stop(workspaceId: string): Promise<void> {
|
||||
const managed = this.processes.get(workspaceId)
|
||||
if (!managed) return
|
||||
|
||||
managed.requestedStop = true
|
||||
const child = managed.child
|
||||
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
child.removeListener("exit", onExit)
|
||||
child.removeListener("error", onError)
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = (error: Error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const resolveIfAlreadyExited = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||
cleanup()
|
||||
resolve()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
child.once("exit", onExit)
|
||||
child.once("error", onError)
|
||||
|
||||
if (resolveIfAlreadyExited()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
|
||||
child.kill("SIGTERM")
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
|
||||
child.kill("SIGKILL")
|
||||
} else {
|
||||
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
private emitLog(workspaceId: string, level: LogLevel, message: string) {
|
||||
const entry: WorkspaceLogEntry = {
|
||||
workspaceId,
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: message.trim(),
|
||||
}
|
||||
|
||||
this.eventBus.publish({ type: "workspace.log", entry })
|
||||
}
|
||||
|
||||
private validateFolder(folder: string) {
|
||||
const resolved = path.resolve(folder)
|
||||
if (!existsSync(resolved)) {
|
||||
throw new Error(`Folder does not exist: ${resolved}`)
|
||||
}
|
||||
const stats = statSync(resolved)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${resolved}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/server/tsconfig.json
Normal file
17
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
7
packages/tauri-app/.gitignore
vendored
Normal file
7
packages/tauri-app/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
src-tauri/target
|
||||
src-tauri/Cargo.lock
|
||||
src-tauri/resources/
|
||||
target
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
5589
packages/tauri-app/Cargo.lock
generated
Normal file
5589
packages/tauri-app/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
packages/tauri-app/Cargo.toml
Normal file
3
packages/tauri-app/Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["src-tauri"]
|
||||
resolver = "2"
|
||||
17
packages/tauri-app/package.json
Normal file
17
packages/tauri-app/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
"dev:prep": "node ./scripts/dev-prep.js",
|
||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||
"prebuild": "node ./scripts/prebuild.js",
|
||||
"bundle:server": "npm run prebuild",
|
||||
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
}
|
||||
46
packages/tauri-app/scripts/dev-prep.js
Normal file
46
packages/tauri-app/scripts/dev-prep.js
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
const uiRoot = path.resolve(root, "..", "ui")
|
||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[dev-prep] UI loader build missing; running workspace build…")
|
||||
execSync("npm --workspace @codenomad/ui run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
if (!fs.existsSync(loadingHtml)) {
|
||||
throw new Error("[dev-prep] failed to produce loading.html after UI build")
|
||||
}
|
||||
}
|
||||
|
||||
function copyUiLoadingAssets() {
|
||||
const loadingSource = path.join(uiDist, "loading.html")
|
||||
const assetsSource = path.join(uiDist, "assets")
|
||||
|
||||
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||
|
||||
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||
if (fs.existsSync(assetsSource)) {
|
||||
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||
}
|
||||
|
||||
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureUiBuild()
|
||||
copyUiLoadingAssets()
|
||||
195
packages/tauri-app/scripts/prebuild.js
Normal file
195
packages/tauri-app/scripts/prebuild.js
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
const serverRoot = path.resolve(root, "..", "server")
|
||||
const uiRoot = path.resolve(root, "..", "ui")
|
||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
|
||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||
|
||||
const sources = ["dist", "public", "node_modules", "package.json"]
|
||||
|
||||
const serverInstallCommand =
|
||||
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
|
||||
const serverDevInstallCommand =
|
||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const uiDevInstallCommand =
|
||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
|
||||
const envWithRootBin = {
|
||||
...process.env,
|
||||
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||
}
|
||||
|
||||
const braceExpansionPath = path.join(
|
||||
serverRoot,
|
||||
"node_modules",
|
||||
"@fastify",
|
||||
"static",
|
||||
"node_modules",
|
||||
"brace-expansion",
|
||||
"package.json",
|
||||
)
|
||||
|
||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||
|
||||
function ensureServerBuild() {
|
||||
const distPath = path.join(serverRoot, "dist")
|
||||
const publicPath = path.join(serverRoot, "public")
|
||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] server build missing; running workspace build...")
|
||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||
throw new Error("[prebuild] server artifacts still missing after build")
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ui build missing; running workspace build...")
|
||||
execSync("npm --workspace @codenomad/ui run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
if (!fs.existsSync(loadingHtml)) {
|
||||
throw new Error("[prebuild] ui loading assets missing after build")
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServerDevDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring server build dependencies (with dev)...")
|
||||
execSync(serverDevInstallCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring server production dependencies...")
|
||||
execSync(serverInstallCommand, {
|
||||
cwd: serverRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
}
|
||||
|
||||
function ensureUiDevDependencies() {
|
||||
if (fs.existsSync(viteBinPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring ui build dependencies...")
|
||||
execSync(uiDevInstallCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureRollupPlatformBinary() {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformPackages = {
|
||||
"linux-x64": "@rollup/rollup-linux-x64-gnu",
|
||||
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
||||
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
||||
"darwin-x64": "@rollup/rollup-darwin-x64",
|
||||
"win32-x64": "@rollup/rollup-win32-x64-msvc",
|
||||
}
|
||||
|
||||
const pkgName = platformPackages[platformKey]
|
||||
if (!pkgName) {
|
||||
return
|
||||
}
|
||||
|
||||
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
|
||||
if (fs.existsSync(platformPackagePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
let rollupVersion = ""
|
||||
try {
|
||||
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
|
||||
} catch (error) {
|
||||
// leave version empty; fallback install will use latest compatible
|
||||
}
|
||||
|
||||
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
|
||||
|
||||
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
|
||||
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(serverDest, { recursive: true })
|
||||
|
||||
for (const name of sources) {
|
||||
const from = path.join(serverRoot, name)
|
||||
const to = path.join(serverDest, name)
|
||||
if (!fs.existsSync(from)) {
|
||||
console.warn(`[prebuild] skipped missing ${from}`)
|
||||
continue
|
||||
}
|
||||
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||
console.log(`[prebuild] copied ${from} -> ${to}`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyUiLoadingAssets() {
|
||||
const loadingSource = path.join(uiDist, "loading.html")
|
||||
const assetsSource = path.join(uiDist, "assets")
|
||||
|
||||
if (!fs.existsSync(loadingSource)) {
|
||||
throw new Error("[prebuild] cannot find built loading.html")
|
||||
}
|
||||
|
||||
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||
|
||||
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||
if (fs.existsSync(assetsSource)) {
|
||||
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||
}
|
||||
|
||||
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureServerDevDependencies()
|
||||
ensureUiDevDependencies()
|
||||
ensureRollupPlatformBinary()
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
copyServerArtifacts()
|
||||
copyUiLoadingAssets()
|
||||
23
packages/tauri-app/src-tauri/Cargo.toml
Normal file
23
packages/tauri-app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
url = "2"
|
||||
3
packages/tauri-app/src-tauri/build.rs
Normal file
3
packages/tauri-app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
16
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
16
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/capabilities.json",
|
||||
"identifier": "main-window-native-dialogs",
|
||||
"description": "Grant the main window access to required core features and native dialog commands.",
|
||||
"remote": {
|
||||
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
|
||||
},
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:menu:default",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls",
|
||||
"core:webview:allow-set-webview-zoom"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
||||
2543
packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2543
packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2543
packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json
Normal file
2543
packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
packages/tauri-app/src-tauri/icon.icns
Normal file
BIN
packages/tauri-app/src-tauri/icon.icns
Normal file
Binary file not shown.
BIN
packages/tauri-app/src-tauri/icon.ico
Normal file
BIN
packages/tauri-app/src-tauri/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
packages/tauri-app/src-tauri/icon.png
Normal file
BIN
packages/tauri-app/src-tauri/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
712
packages/tauri-app/src-tauri/src/cli_manager.rs
Normal file
712
packages/tauri-app/src-tauri/src/cli_manager.rs
Normal file
@@ -0,0 +1,712 @@
|
||||
use dirs::home_dir;
|
||||
use parking_lot::Mutex;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
}
|
||||
|
||||
fn workspace_root() -> Option<PathBuf> {
|
||||
std::env::current_dir().ok().and_then(|mut dir| {
|
||||
for _ in 0..3 {
|
||||
if let Some(parent) = dir.parent() {
|
||||
dir = parent.to_path_buf();
|
||||
}
|
||||
}
|
||||
Some(dir)
|
||||
})
|
||||
}
|
||||
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
log_line(&format!("navigating main to {url}"));
|
||||
if let Ok(parsed) = Url::parse(url) {
|
||||
let _ = win.navigate(parsed);
|
||||
} else {
|
||||
log_line("failed to parse URL for navigation");
|
||||
}
|
||||
} else {
|
||||
log_line("main window not found for navigation");
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PreferencesConfig {
|
||||
#[serde(rename = "listeningMode")]
|
||||
listening_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppConfig {
|
||||
preferences: Option<PreferencesConfig>,
|
||||
}
|
||||
|
||||
fn resolve_config_path() -> PathBuf {
|
||||
let raw = env::var("CLI_CONFIG")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||
expand_home(&raw)
|
||||
}
|
||||
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
if path.starts_with("~/") {
|
||||
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
|
||||
return home.join(path.trim_start_matches("~/"));
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
fn resolve_listening_mode() -> String {
|
||||
let path = resolve_config_path();
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||
if let Some(mode) = config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
{
|
||||
if mode == "local" {
|
||||
return "local".to_string();
|
||||
}
|
||||
if mode == "all" {
|
||||
return "all".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"local".to_string()
|
||||
}
|
||||
|
||||
fn resolve_listening_host() -> String {
|
||||
let mode = resolve_listening_mode();
|
||||
if mode == "local" {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CliState {
|
||||
Starting,
|
||||
Ready,
|
||||
Error,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CliStatus {
|
||||
pub state: CliState,
|
||||
pub pid: Option<u32>,
|
||||
pub port: Option<u16>,
|
||||
pub url: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CliStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: CliState::Stopped,
|
||||
pid: None,
|
||||
port: None,
|
||||
url: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CliProcessManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
ready: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
|
||||
log_line(&format!("start requested (dev={dev})"));
|
||||
self.stop()?;
|
||||
self.ready.store(false, Ordering::SeqCst);
|
||||
{
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Starting;
|
||||
status.port = None;
|
||||
status.url = None;
|
||||
status.error = None;
|
||||
status.pid = None;
|
||||
}
|
||||
Self::emit_status(&app, &self.status.lock());
|
||||
|
||||
let status_arc = self.status.clone();
|
||||
let child_arc = self.child.clone();
|
||||
let ready_flag = self.ready.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
|
||||
log_line(&format!("cli spawn failed: {err}"));
|
||||
let mut locked = status_arc.lock();
|
||||
locked.state = CliState::Error;
|
||||
locked.error = Some(err.to_string());
|
||||
let snapshot = locked.clone();
|
||||
drop(locked);
|
||||
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
|
||||
let _ = app.emit("cli:status", snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if start.elapsed() > Duration::from_secs(4) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Stopped;
|
||||
status.pid = None;
|
||||
status.port = None;
|
||||
status.url = None;
|
||||
status.error = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(&self) -> CliStatus {
|
||||
self.status.lock().clone()
|
||||
}
|
||||
|
||||
fn spawn_cli(
|
||||
app: AppHandle,
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child_holder: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
dev: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
log_line("resolving CLI entry");
|
||||
let resolution = CliEntry::resolve(&app, dev)?;
|
||||
let host = resolve_listening_host();
|
||||
log_line(&format!(
|
||||
"resolved CLI entry runner={:?} entry={} host={}",
|
||||
resolution.runner, resolution.entry, host
|
||||
));
|
||||
let args = resolution.build_args(dev, &host);
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
}
|
||||
|
||||
let cwd = workspace_root();
|
||||
if let Some(ref c) = cwd {
|
||||
log_line(&format!("using cwd={}", c.display()));
|
||||
}
|
||||
|
||||
let command_info = if supports_user_shell() {
|
||||
log_line("spawning via user shell");
|
||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||
} else {
|
||||
log_line("spawning directly with node");
|
||||
ShellCommandType::Direct(DirectCommand {
|
||||
program: resolution.node_binary.clone(),
|
||||
args: resolution.runner_args(&args),
|
||||
})
|
||||
};
|
||||
|
||||
if !supports_user_shell() {
|
||||
if which::which(&resolution.node_binary).is_err() {
|
||||
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
|
||||
}
|
||||
}
|
||||
|
||||
let child = match &command_info {
|
||||
ShellCommandType::UserShell(cmd) => {
|
||||
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
||||
let mut c = Command::new(&cmd.shell);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
c.spawn()?
|
||||
}
|
||||
ShellCommandType::Direct(cmd) => {
|
||||
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
||||
let mut c = Command::new(&cmd.program);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
c.spawn()?
|
||||
}
|
||||
};
|
||||
|
||||
let pid = child.id();
|
||||
log_line(&format!("spawned pid={pid}"));
|
||||
{
|
||||
let mut locked = status.lock();
|
||||
locked.pid = Some(pid);
|
||||
}
|
||||
Self::emit_status(&app, &status.lock());
|
||||
|
||||
{
|
||||
let mut holder = child_holder.lock();
|
||||
*holder = Some(child);
|
||||
}
|
||||
|
||||
let child_clone = child_holder.clone();
|
||||
let status_clone = status.clone();
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let stdout = child_clone
|
||||
.lock()
|
||||
.as_mut()
|
||||
.and_then(|c| c.stdout.take())
|
||||
.map(BufReader::new);
|
||||
let stderr = child_clone
|
||||
.lock()
|
||||
.as_mut()
|
||||
.and_then(|c| c.stderr.take())
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
||||
}
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
||||
}
|
||||
});
|
||||
|
||||
let app_clone = app.clone();
|
||||
let status_clone = status.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let child_holder_clone = child_holder.clone();
|
||||
thread::spawn(move || {
|
||||
let timeout = Duration::from_secs(60);
|
||||
thread::sleep(timeout);
|
||||
if ready_clone.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let mut locked = status_clone.lock();
|
||||
locked.state = CliState::Error;
|
||||
locked.error = Some("CLI did not start in time".to_string());
|
||||
log_line("timeout waiting for CLI readiness");
|
||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
});
|
||||
|
||||
let status_clone = status.clone();
|
||||
let app_clone = app.clone();
|
||||
thread::spawn(move || {
|
||||
let code = {
|
||||
let mut guard = child_holder.lock();
|
||||
if let Some(child) = guard.as_mut() {
|
||||
child.wait().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let mut locked = status_clone.lock();
|
||||
let failed = locked.state != CliState::Ready;
|
||||
let err_msg = if failed {
|
||||
Some(match code {
|
||||
Some(status) => format!("CLI exited early: {status}"),
|
||||
None => "CLI exited early".to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if failed {
|
||||
locked.state = CliState::Error;
|
||||
if locked.error.is_none() {
|
||||
locked.error = err_msg.clone();
|
||||
}
|
||||
log_line(&format!("cli process exited before ready: {:?}", locked.error));
|
||||
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
|
||||
} else {
|
||||
locked.state = CliState::Stopped;
|
||||
log_line("cli process stopped cleanly");
|
||||
}
|
||||
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_stream<R: BufRead>(
|
||||
mut reader: R,
|
||||
stream: &str,
|
||||
app: &AppHandle,
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
|
||||
loop {
|
||||
buffer.clear();
|
||||
match reader.read_line(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let line = buffer.trim_end();
|
||||
if !line.is_empty() {
|
||||
log_line(&format!("[cli][{}] {}", stream, line));
|
||||
|
||||
if ready.load(Ordering::SeqCst) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(port) = port_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, port);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.to_lowercase().contains("http server listening") {
|
||||
if let Some(port) = http_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, port);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(app, status, ready, port as u16);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
let mut locked = status.lock();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
locked.port = Some(port);
|
||||
locked.url = Some(url.clone());
|
||||
locked.state = CliState::Ready;
|
||||
locked.error = None;
|
||||
log_line(&format!("cli ready on {url}"));
|
||||
navigate_main(app, &url);
|
||||
let _ = app.emit("cli:ready", locked.clone());
|
||||
Self::emit_status(app, &locked);
|
||||
}
|
||||
|
||||
fn emit_status(app: &AppHandle, status: &CliStatus) {
|
||||
let _ = app.emit("cli:status", status.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_user_shell() -> bool {
|
||||
cfg!(unix)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShellCommand {
|
||||
shell: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DirectCommand {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ShellCommandType {
|
||||
UserShell(ShellCommand),
|
||||
Direct(DirectCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CliEntry {
|
||||
entry: String,
|
||||
runner: Runner,
|
||||
runner_path: Option<String>,
|
||||
node_binary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Runner {
|
||||
Node,
|
||||
Tsx,
|
||||
}
|
||||
|
||||
impl CliEntry {
|
||||
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
|
||||
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
|
||||
|
||||
if dev {
|
||||
if let Some(tsx_path) = resolve_tsx(app) {
|
||||
if let Some(entry) = resolve_dev_entry(app) {
|
||||
return Ok(Self {
|
||||
entry,
|
||||
runner: Runner::Tsx,
|
||||
runner_path: Some(tsx_path),
|
||||
node_binary,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = resolve_dist_entry(app) {
|
||||
return Ok(Self {
|
||||
entry,
|
||||
runner: Runner::Node,
|
||||
runner_path: None,
|
||||
node_binary,
|
||||
});
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--port".to_string(),
|
||||
"0".to_string(),
|
||||
];
|
||||
if dev {
|
||||
args.push("--ui-dev-server".to_string());
|
||||
args.push("http://localhost:3000".to_string());
|
||||
args.push("--log-level".to_string());
|
||||
args.push("debug".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
||||
let mut args = VecDeque::new();
|
||||
if self.runner == Runner::Tsx {
|
||||
if let Some(path) = &self.runner_path {
|
||||
args.push_back(path.clone());
|
||||
}
|
||||
}
|
||||
args.push_back(self.entry.clone());
|
||||
for arg in cli_args {
|
||||
args.push_back(arg.clone());
|
||||
}
|
||||
args.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("packages/server/src/index.ts")),
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("../server/src/index.ts")),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
let base = workspace_root();
|
||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||
];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let resources = dir.join("../Resources");
|
||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/server/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
||||
|
||||
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||
for root in linux_resource_roots {
|
||||
candidates.push(Some(root.join("server/dist/bin.js")));
|
||||
candidates.push(Some(root.join("server/dist/index.js")));
|
||||
candidates.push(Some(root.join("server/dist/server/bin.js")));
|
||||
candidates.push(Some(root.join("server/dist/server/index.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
|
||||
|
||||
let shell = default_shell();
|
||||
let mut quoted: Vec<String> = Vec::new();
|
||||
quoted.push(shell_escape(&entry.node_binary));
|
||||
for arg in entry.runner_args(cli_args) {
|
||||
quoted.push(shell_escape(&arg));
|
||||
}
|
||||
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
|
||||
let args = build_shell_args(&shell, &command);
|
||||
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||
Ok(ShellCommand { shell, args })
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
if let Ok(shell) = std::env::var("SHELL") {
|
||||
if !shell.trim().is_empty() {
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
"/bin/zsh".to_string()
|
||||
} else {
|
||||
"/bin/bash".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_escape(input: &str) -> String {
|
||||
if input.is_empty() {
|
||||
"''".to_string()
|
||||
} else if !input
|
||||
.chars()
|
||||
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
|
||||
{
|
||||
input.to_string()
|
||||
} else {
|
||||
let escaped = input.replace('\'', "'\\''");
|
||||
format!("'{}'", escaped)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
||||
let shell_name = std::path::Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if shell_name.contains("zsh") {
|
||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||
} else {
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||
paths
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|p| p.exists())
|
||||
.map(|p| normalize_path(p))
|
||||
}
|
||||
|
||||
fn normalize_path(path: PathBuf) -> String {
|
||||
if let Ok(clean) = path.canonicalize() {
|
||||
clean.to_string_lossy().to_string()
|
||||
} else {
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
267
packages/tauri-app/src-tauri/src/main.rs
Normal file
267
packages/tauri-app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod cli_manager;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use serde_json::json;
|
||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::Webview;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
||||
state.manager.status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
|
||||
let dev_mode = is_dev_mode();
|
||||
state.manager.stop().map_err(|e| e.to_string())?;
|
||||
state
|
||||
.manager
|
||||
.start(app, dev_mode)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||
}
|
||||
|
||||
fn should_allow_internal(url: &Url) -> bool {
|
||||
match url.scheme() {
|
||||
"tauri" | "asset" | "file" => true,
|
||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
if should_allow_internal(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Err(err) = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
.open_url(url.as_str(), None::<&str>)
|
||||
{
|
||||
eprintln!("[tauri] failed to open external link {}: {}", url, err);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
.build();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
})
|
||||
.setup(|app| {
|
||||
build_menu(&app.handle())?;
|
||||
let dev_mode = is_dev_mode();
|
||||
let app_handle = app.handle().clone();
|
||||
let manager = app.state::<AppState>().manager.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
|
||||
let _ = app_handle.emit("cli:error", json!({"message": err.to_string()}));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
// File menu
|
||||
"new_instance" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.emit("menu:newInstance", ());
|
||||
}
|
||||
}
|
||||
"close" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
// View menu
|
||||
"reload" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload()");
|
||||
}
|
||||
}
|
||||
"force_reload" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload(true)");
|
||||
}
|
||||
}
|
||||
"toggle_devtools" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
|
||||
"toggle_fullscreen" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
|
||||
}
|
||||
}
|
||||
|
||||
// Window menu
|
||||
"minimize" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.minimize();
|
||||
}
|
||||
}
|
||||
"zoom" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
// App menu (macOS)
|
||||
"about" => {
|
||||
// TODO: Implement about dialog
|
||||
println!("About menu item clicked");
|
||||
}
|
||||
"hide" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
"hide_others" => {
|
||||
// TODO: Hide other app windows
|
||||
println!("Hide Others menu item clicked");
|
||||
}
|
||||
"show_all" => {
|
||||
// TODO: Show all app windows
|
||||
println!("Show All menu item clicked");
|
||||
}
|
||||
|
||||
_ => {
|
||||
println!("Unhandled menu event: {}", event.id().0);
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
..
|
||||
} => {
|
||||
if app_handle.webview_windows().len() <= 1 {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
let is_mac = cfg!(target_os = "macos");
|
||||
|
||||
// Create submenus
|
||||
let mut submenus = Vec::new();
|
||||
|
||||
// App menu (macOS only)
|
||||
if is_mac {
|
||||
let app_menu = SubmenuBuilder::new(app, "CodeNomad")
|
||||
.text("about", "About CodeNomad")
|
||||
.separator()
|
||||
.text("hide", "Hide CodeNomad")
|
||||
.text("hide_others", "Hide Others")
|
||||
.text("show_all", "Show All")
|
||||
.separator()
|
||||
.text("quit", "Quit CodeNomad")
|
||||
.build()?;
|
||||
submenus.push(app_menu);
|
||||
}
|
||||
|
||||
// File menu - create New Instance with accelerator
|
||||
let new_instance_item = MenuItem::with_id(
|
||||
app,
|
||||
"new_instance",
|
||||
"New Instance",
|
||||
true,
|
||||
Some("CmdOrCtrl+N")
|
||||
)?;
|
||||
|
||||
let file_menu = SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
||||
.build()?;
|
||||
submenus.push(file_menu);
|
||||
|
||||
// Edit menu with predefined items for standard functionality
|
||||
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||
.undo()
|
||||
.redo()
|
||||
.separator()
|
||||
.cut()
|
||||
.copy()
|
||||
.paste()
|
||||
.separator()
|
||||
.select_all()
|
||||
.build()?;
|
||||
submenus.push(edit_menu);
|
||||
|
||||
// View menu
|
||||
let view_menu = SubmenuBuilder::new(app, "View")
|
||||
.text("reload", "Reload")
|
||||
.text("force_reload", "Force Reload")
|
||||
.text("toggle_devtools", "Toggle Developer Tools")
|
||||
.separator()
|
||||
|
||||
.separator()
|
||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||
.build()?;
|
||||
submenus.push(view_menu);
|
||||
|
||||
// Window menu
|
||||
let window_menu = SubmenuBuilder::new(app, "Window")
|
||||
.text("minimize", "Minimize")
|
||||
.text("zoom", "Zoom")
|
||||
.build()?;
|
||||
submenus.push(window_menu);
|
||||
|
||||
// Build the main menu with all submenus
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||
|
||||
app.set_menu(menu)?;
|
||||
Ok(())
|
||||
}
|
||||
50
packages/tauri-app/src-tauri/tauri.conf.json
Normal file
50
packages/tauri-app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.opencode.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
"frontendDist": "resources/ui-loading"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "CodeNomad",
|
||||
"url": "loading.html",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"theme": "Dark",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"zoomHotkeysEnabled": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"scope": ["**"]
|
||||
},
|
||||
"capabilities": ["main-window-native-dialogs"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"resources": [
|
||||
"resources/server",
|
||||
"resources/ui-loading"
|
||||
],
|
||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||
}
|
||||
}
|
||||
3
packages/ui/.gitignore
vendored
Normal file
3
packages/ui/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
54
packages/ui/README.md
Normal file
54
packages/ui/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# CodeNomad UI
|
||||
|
||||
This package contains the frontend user interface for CodeNomad, built with [SolidJS](https://www.solidjs.com/) and [Tailwind CSS](https://tailwindcss.com/).
|
||||
|
||||
## Overview
|
||||
|
||||
The UI is designed to be a high-performance, low-latency cockpit for managing OpenCode sessions. It connects to the CodeNomad server (either running locally via CLI or embedded in the Electron app).
|
||||
|
||||
## Features
|
||||
|
||||
- **SolidJS**: Fine-grained reactivity for high performance.
|
||||
- **Tailwind CSS**: Utility-first styling for rapid development.
|
||||
- **Vite**: Fast build tool and dev server.
|
||||
|
||||
## Development
|
||||
|
||||
To run the UI in standalone mode (connected to a running server):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts the Vite dev server at `http://localhost:3000`.
|
||||
|
||||
## Building
|
||||
|
||||
To build the production assets:
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
|
||||
|
||||
## Debug Logging
|
||||
|
||||
The UI now routes all logging through a lightweight wrapper around [`debug`](https://github.com/debug-js/debug). The logger exposes four namespaces that can be toggled at runtime:
|
||||
|
||||
- `sse` – Server-sent event transport and handlers
|
||||
- `api` – HTTP/API calls and workspace lifecycle
|
||||
- `session` – Session/model state, prompt handling, tool calls
|
||||
- `actions` – User-driven interactions in UI components
|
||||
|
||||
You can enable or disable namespaces from DevTools (in dev or production builds) via the global `window.codenomadLogger` helpers:
|
||||
|
||||
```js
|
||||
window.codenomadLogger?.listLoggerNamespaces() // => [{ name: "sse", enabled: false }, ...]
|
||||
window.codenomadLogger?.enableLogger("sse") // turn on SSE logs
|
||||
window.codenomadLogger?.disableLogger("sse") // turn them off again
|
||||
window.codenomadLogger?.enableAllLoggers() // optional helper
|
||||
```
|
||||
|
||||
Enabled namespaces are persisted in `localStorage` under `opencode:logger:namespaces`, so your preference survives reloads.
|
||||
|
||||
40
packages/ui/package.json
Normal file
40
packages/ui/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "node --test --experimental-strip-types src/lib/__tests__/*.test.ts src/stores/__tests__/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "^1.0.138",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "10.4.21",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
11
packages/ui/postcss.config.js
Normal file
11
packages/ui/postcss.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { fileURLToPath } from "url"
|
||||
import { dirname, resolve } from "path"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: { config: resolve(__dirname, "tailwind.config.js") },
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user