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:
Gemini AI
2025-12-24 22:20:13 +04:00
Unverified
parent e17e7cd32e
commit 743d0367e2
9 changed files with 805 additions and 15 deletions

View 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)
}
}
}

View 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"

View 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
}
}

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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 })

View 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
})
}
})
}

View File

@@ -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()

View File

@@ -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">