diff --git a/packages/server/src/context-engine/client.ts b/packages/server/src/context-engine/client.ts new file mode 100644 index 0000000..a0a613a --- /dev/null +++ b/packages/server/src/context-engine/client.ts @@ -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 + }> + total_results: number +} + +export interface MemoryRequest { + text: string + metadata?: Record +} + +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 = {}, logger: Logger) { + this.config = { ...DEFAULT_CONFIG, ...config } + this.logger = logger + } + + /** + * Check if the Context-Engine is healthy and responding + */ + async health(): Promise { + try { + const response = await this.request("/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 { + this.logger.info({ path, recursive }, "Triggering Context-Engine indexing") + + try { + const response = await this.request("/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 { + this.logger.debug({ prompt: prompt.slice(0, 100), contextWindow, topK }, "Querying Context-Engine") + + try { + const response = await this.request("/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): Promise { + this.logger.debug({ textLength: text.length }, "Adding memory to Context-Engine") + + try { + const response = await this.request("/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(endpoint: string, options: RequestInit): Promise { + 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) + } + } +} diff --git a/packages/server/src/context-engine/index.ts b/packages/server/src/context-engine/index.ts new file mode 100644 index 0000000..78b6111 --- /dev/null +++ b/packages/server/src/context-engine/index.ts @@ -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" diff --git a/packages/server/src/context-engine/service.ts b/packages/server/src/context-engine/service.ts new file mode 100644 index 0000000..c015aca --- /dev/null +++ b/packages/server/src/context-engine/service.ts @@ -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() + + constructor(config: Partial = {}, logger: Logger) { + super() + this.config = { ...DEFAULT_SERVICE_CONFIG, ...config } + this.logger = logger + + const clientConfig: Partial = { + 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 { + 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 { + 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((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 { + 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 { + 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 `\n${contextParts.join("\n\n")}\n` + } 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 { + 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, + logger: Logger +): ContextEngineService { + if (globalContextEngineService) { + return globalContextEngineService + } + globalContextEngineService = new ContextEngineService(config, logger) + return globalContextEngineService +} + +export async function shutdownContextEngineService(): Promise { + if (globalContextEngineService) { + await globalContextEngineService.stop() + globalContextEngineService = null + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 959f9a6..b22531a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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") diff --git a/packages/server/src/integrations/ollama-cloud.ts b/packages/server/src/integrations/ollama-cloud.ts index 9da6392..7448c50 100644 --- a/packages/server/src/integrations/ollama-cloud.ts +++ b/packages/server/src/integrations/ollama-cloud.ts @@ -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) { diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 948fd12..eee7cbb 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -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 }) diff --git a/packages/server/src/server/routes/context-engine.ts b/packages/server/src/server/routes/context-engine.ts new file mode 100644 index 0000000..b49d4a1 --- /dev/null +++ b/packages/server/src/server/routes/context-engine.ts @@ -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 = {} + 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 + }) + } + }) +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 3e681e8..c746145 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -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() @@ -67,7 +68,7 @@ export class WorkspaceManager { } async create(folder: string, name?: string): Promise { - + 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 { 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() diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index 1b926ac..e661c26 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -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>(new Set()) + + // Context-Engine status: "stopped" | "ready" | "indexing" | "error" + type ContextEngineStatus = "stopped" | "ready" | "indexing" | "error" + const [contextEngineStatus, setContextEngineStatus] = createSignal("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) && ( Offline )} + {/* Context-Engine RAG Status Indicator */} + + + + + + {currentModelValue() && (