Integrate Context-Engine RAG service for enhanced LLM responses
Backend: - Created context-engine/client.ts - HTTP client for Context-Engine API - Created context-engine/service.ts - Lifecycle management of Context-Engine sidecar - Created context-engine/index.ts - Module exports - Created server/routes/context-engine.ts - API endpoints for status/health/query Integration: - workspaces/manager.ts: Trigger indexing when workspace becomes ready (non-blocking) - index.ts: Initialize ContextEngineService on server start (lazy mode) - ollama-cloud.ts: Inject RAG context into chat requests when available Frontend: - model-selector.tsx: Added Context-Engine status indicator - Green dot = Ready (RAG enabled) - Blue pulsing dot = Indexing - Red dot = Error - Hidden when Context-Engine not running All operations are non-blocking with graceful fallback when Context-Engine is unavailable.
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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)
|
||||
|
||||
@@ -140,6 +141,16 @@ async function main() {
|
||||
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`,
|
||||
@@ -211,6 +222,13 @@ async function main() {
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod"
|
||||
import { getContextEngineService } from "../context-engine"
|
||||
|
||||
export const OllamaCloudConfigSchema = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
@@ -208,11 +209,41 @@ export class OllamaCloudClient {
|
||||
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(request)
|
||||
body: JSON.stringify(enrichedRequest)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
|
||||
@@ -124,6 +125,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerZAIRoutes(app, { logger: deps.logger })
|
||||
registerOpenCodeZenRoutes(app, { logger: deps.logger })
|
||||
registerSkillsRoutes(app)
|
||||
registerContextEngineRoutes(app)
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../
|
||||
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
|
||||
|
||||
@@ -22,7 +23,7 @@ interface WorkspaceManagerOptions {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor { }
|
||||
|
||||
export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
@@ -67,7 +68,7 @@ export class WorkspaceManager {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -126,18 +127,27 @@ export class WorkspaceManager {
|
||||
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 })
|
||||
@@ -330,11 +340,11 @@ export class WorkspaceManager {
|
||||
|
||||
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: {
|
||||
@@ -342,11 +352,11 @@ export class WorkspaceManager {
|
||||
},
|
||||
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 }
|
||||
@@ -379,7 +389,7 @@ export class WorkspaceManager {
|
||||
|
||||
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
|
||||
@@ -398,10 +408,10 @@ export class WorkspaceManager {
|
||||
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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Combobox } from "@kobalte/core/combobox"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import { ChevronDown, Database } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getUserScopedKey } from "../lib/user-storage"
|
||||
@@ -29,6 +29,11 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const qwenAuth = useQwenOAuth()
|
||||
const [offlineModels, setOfflineModels] = createSignal<Set<string>>(new Set())
|
||||
|
||||
// Context-Engine status: "stopped" | "ready" | "indexing" | "error"
|
||||
type ContextEngineStatus = "stopped" | "ready" | "indexing" | "error"
|
||||
const [contextEngineStatus, setContextEngineStatus] = createSignal<ContextEngineStatus>("stopped")
|
||||
|
||||
let triggerRef!: HTMLButtonElement
|
||||
let searchInputRef!: HTMLInputElement
|
||||
|
||||
@@ -64,9 +69,28 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
}
|
||||
window.addEventListener("opencode-zen-offline-models", handleCustom as EventListener)
|
||||
window.addEventListener("storage", handleStorage)
|
||||
|
||||
// Poll Context-Engine status
|
||||
const pollContextEngine = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/context-engine/status")
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { status: ContextEngineStatus }
|
||||
setContextEngineStatus(data.status ?? "stopped")
|
||||
} else {
|
||||
setContextEngineStatus("stopped")
|
||||
}
|
||||
} catch {
|
||||
setContextEngineStatus("stopped")
|
||||
}
|
||||
}
|
||||
pollContextEngine()
|
||||
const pollInterval = setInterval(pollContextEngine, 5000)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("opencode-zen-offline-models", handleCustom as EventListener)
|
||||
window.removeEventListener("storage", handleStorage)
|
||||
clearInterval(pollInterval)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,6 +194,29 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
{currentModelValue() && isOfflineModel(currentModelValue() as FlatModel) && (
|
||||
<span class="selector-badge selector-badge-warning">Offline</span>
|
||||
)}
|
||||
{/* Context-Engine RAG Status Indicator */}
|
||||
<Show when={contextEngineStatus() !== "stopped"}>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-[10px]"
|
||||
title={
|
||||
contextEngineStatus() === "ready"
|
||||
? "Context Engine is active - RAG enabled"
|
||||
: contextEngineStatus() === "indexing"
|
||||
? "Context Engine is indexing files..."
|
||||
: "Context Engine error"
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={`w-2 h-2 rounded-full ${contextEngineStatus() === "ready"
|
||||
? "bg-emerald-500"
|
||||
: contextEngineStatus() === "indexing"
|
||||
? "bg-blue-500 animate-pulse"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<Database class="w-3 h-3 text-zinc-400" />
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary">
|
||||
|
||||
Reference in New Issue
Block a user