From c4ac0796609e33420a57d5f145dec71ccbfa7391 Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Tue, 23 Dec 2025 13:18:37 +0400 Subject: [PATCH] restore: bring back all custom UI enhancements from checkpoint Restored from commit 52be710 (checkpoint before qwen oauth + todo roller): Enhanced UI Features: - SMART FIX button with AI code analysis - APEX (Autonomous Programming EXecution) mode - SHIELD (Auto-approval) mode - MULTIX MODE multi-task pipeline interface - Live streaming token counter - Thinking indicator with bouncing dots animation Components restored: - packages/ui/src/components/chat/multi-task-chat.tsx - packages/ui/src/components/instance/instance-shell2.tsx - packages/ui/src/components/settings/OllamaCloudSettings.tsx - packages/ui/src/components/settings/QwenCodeSettings.tsx - packages/ui/src/stores/solo-store.ts - packages/ui/src/stores/task-actions.ts - packages/ui/src/stores/session-events.ts (autonomous mode) - packages/server/src/integrations/ollama-cloud.ts - packages/server/src/server/routes/ollama.ts - packages/server/src/server/routes/qwen.ts This ensures all custom features are preserved in source control. --- packages/electron-app/scripts/dev.sh | 0 packages/server/package.json | 5 +- packages/server/src/api-types.ts | 15 + .../server/src/integrations/ollama-cloud.ts | 273 ++++++++ packages/server/src/server/http-server.ts | 4 + packages/server/src/server/routes/ollama.ts | 224 +++++++ packages/server/src/server/routes/qwen.ts | 134 ++++ packages/server/src/server/routes/storage.ts | 12 +- .../server/src/server/routes/workspaces.ts | 14 +- packages/server/src/storage/instance-store.ts | 1 + packages/server/src/workspaces/manager.ts | 226 ++++++- packages/server/src/workspaces/runtime.ts | 72 ++- packages/ui/src/App.tsx | 83 ++- .../components/advanced-settings-modal.tsx | 87 ++- .../chat/multi-task-chat-backup.tsx | 334 ++++++++++ .../src/components/chat/multi-task-chat.tsx | 503 +++++++++++++++ .../ui/src/components/instance/editor.tsx | 52 ++ .../components/instance/instance-shell2.tsx | 591 ++++++++++-------- .../ui/src/components/instance/sidebar.tsx | 203 ++++++ .../ui/src/components/message-block-list.tsx | 13 +- .../src/components/model-status-selector.tsx | 95 +++ packages/ui/src/components/prompt-input.tsx | 4 +- .../settings/OllamaCloudSettings.tsx | 239 +++++++ .../components/settings/QwenCodeSettings.tsx | 191 ++++++ packages/ui/src/index.css | 2 + packages/ui/src/lib/api-client.ts | 35 +- .../ui/src/lib/integrations/qwen-oauth.ts | 486 ++++++++++++++ packages/ui/src/lib/logger.ts | 43 +- packages/ui/src/lib/runtime-env.ts | 13 +- packages/ui/src/lib/storage.ts | 3 + packages/ui/src/pages/QwenOAuthCallback.tsx | 104 +++ packages/ui/src/renderer/loading/main.tsx | 65 +- packages/ui/src/stores/instance-config.tsx | 3 +- packages/ui/src/stores/instances.ts | 25 +- .../src/stores/message-v2/instance-store.ts | 6 +- .../ui/src/stores/message-v2/session-info.ts | 6 + packages/ui/src/stores/message-v2/types.ts | 2 + packages/ui/src/stores/session-actions.ts | 118 +++- packages/ui/src/stores/session-api.ts | 13 +- packages/ui/src/stores/session-events.ts | 174 +++++- packages/ui/src/stores/session-state.ts | 27 + packages/ui/src/stores/solo-store.ts | 77 +++ packages/ui/src/stores/task-actions.ts | 163 +++++ packages/ui/src/styles/antigravity.css | 62 ++ .../ui/src/styles/messaging/prompt-input.css | 142 ++--- packages/ui/src/styles/tabs.css | 120 ++++ packages/ui/src/types/session.ts | 13 + 47 files changed, 4550 insertions(+), 527 deletions(-) mode change 100755 => 100644 packages/electron-app/scripts/dev.sh create mode 100644 packages/server/src/integrations/ollama-cloud.ts create mode 100644 packages/server/src/server/routes/ollama.ts create mode 100644 packages/server/src/server/routes/qwen.ts create mode 100644 packages/ui/src/components/chat/multi-task-chat-backup.tsx create mode 100644 packages/ui/src/components/chat/multi-task-chat.tsx create mode 100644 packages/ui/src/components/instance/editor.tsx create mode 100644 packages/ui/src/components/instance/sidebar.tsx create mode 100644 packages/ui/src/components/model-status-selector.tsx create mode 100644 packages/ui/src/components/settings/OllamaCloudSettings.tsx create mode 100644 packages/ui/src/components/settings/QwenCodeSettings.tsx create mode 100644 packages/ui/src/lib/integrations/qwen-oauth.ts create mode 100644 packages/ui/src/pages/QwenOAuthCallback.tsx create mode 100644 packages/ui/src/stores/solo-store.ts create mode 100644 packages/ui/src/stores/task-actions.ts create mode 100644 packages/ui/src/styles/antigravity.css create mode 100644 packages/ui/src/styles/tabs.css diff --git a/packages/electron-app/scripts/dev.sh b/packages/electron-app/scripts/dev.sh old mode 100755 new mode 100644 diff --git a/packages/server/package.json b/packages/server/package.json index c6892ee..1257f82 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -16,10 +16,11 @@ "codenomad": "dist/bin.js" }, "scripts": { - "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json", + "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config", "build:ui": "npm run build --prefix ../ui", "prepare-ui": "node ./scripts/copy-ui-dist.mjs", - "dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts", + "prepare-config": "node ./scripts/copy-opencode-config.mjs", + "dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 7ad858c..27d98c2 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -8,6 +8,20 @@ import type { RecentFolder, } from "./config/schema" +export type TaskStatus = "completed" | "interrupted" | "in-progress" | "pending" + +export interface Task { + id: string + title: string + status: TaskStatus + timestamp: number + messageIds?: string[] // IDs of messages associated with this task +} + +export interface SessionTasks { + [sessionId: string]: Task[] +} + /** * Canonical HTTP/SSE contract for the CLI server. * These types are consumed by both the CLI implementation and any UI clients. @@ -109,6 +123,7 @@ export type WorkspaceFileSearchResponse = FileSystemEntry[] export interface InstanceData { messageHistory: string[] agentModelSelections: AgentModelSelection + sessionTasks?: SessionTasks // Multi-task chat support: tasks per session } export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected" diff --git a/packages/server/src/integrations/ollama-cloud.ts b/packages/server/src/integrations/ollama-cloud.ts new file mode 100644 index 0000000..0c17a36 --- /dev/null +++ b/packages/server/src/integrations/ollama-cloud.ts @@ -0,0 +1,273 @@ +/** + * Ollama Cloud API Integration + * Provides access to Ollama's cloud models through API + */ + +import { z } from "zod" + +// Configuration schema for Ollama Cloud +export const OllamaCloudConfigSchema = z.object({ + apiKey: z.string().optional(), + endpoint: z.string().default("https://ollama.com"), + enabled: z.boolean().default(false) +}) + +export type OllamaCloudConfig = z.infer + +// Model information schema +export const OllamaModelSchema = z.object({ + name: z.string(), + size: z.string(), + digest: z.string(), + modified_at: z.string(), + created_at: z.string() +}) + +export type OllamaModel = z.infer + +// Chat message schema +export const ChatMessageSchema = z.object({ + role: z.enum(["user", "assistant", "system"]), + content: z.string(), + images: z.array(z.string()).optional() +}) + +export type ChatMessage = z.infer + +// Chat request/response schemas +export const ChatRequestSchema = z.object({ + model: z.string(), + messages: z.array(ChatMessageSchema), + stream: z.boolean().default(false), + options: z.object({ + temperature: z.number().min(0).max(2).optional(), + top_p: z.number().min(0).max(1).optional() + }).optional() +}) + +export const ChatResponseSchema = z.object({ + model: z.string(), + created_at: z.string(), + message: ChatMessageSchema, + done: z.boolean().optional(), + total_duration: z.number().optional(), + load_duration: z.number().optional(), + prompt_eval_count: z.number().optional(), + prompt_eval_duration: z.number().optional(), + eval_count: z.number().optional(), + eval_duration: z.number().optional() +}) + +export type ChatRequest = z.infer +export type ChatResponse = z.infer + +export class OllamaCloudClient { + private config: OllamaCloudConfig + private baseUrl: string + + constructor(config: OllamaCloudConfig) { + this.config = config + this.baseUrl = config.endpoint.replace(/\/$/, "") // Remove trailing slash + } + + /** + * Test connection to Ollama Cloud API + */ + async testConnection(): Promise { + try { + const response = await this.makeRequest("/api/tags", { + method: "GET" + }) + return response.ok + } catch (error) { + console.error("Ollama Cloud connection test failed:", error) + return false + } + } + + /** + * List available models + */ + async listModels(): Promise { + try { + const response = await this.makeRequest("/api/tags", { + method: "GET" + }) + + if (!response.ok) { + throw new Error(`Failed to fetch models: ${response.statusText}`) + } + + const data = await response.json() + return z.array(OllamaModelSchema).parse(data.models || []) + } catch (error) { + console.error("Failed to list Ollama Cloud models:", error) + throw error + } + } + + /** + * Generate chat completion + */ + async chat(request: ChatRequest): Promise> { + if (!this.config.apiKey) { + throw new Error("Ollama Cloud API key is required") + } + + const headers: Record = { + "Content-Type": "application/json" + } + + // Add authorization header if API key is provided + if (this.config.apiKey) { + headers["Authorization"] = `Bearer ${this.config.apiKey}` + } + + try { + const response = await fetch(`${this.baseUrl}/api/chat`, { + method: "POST", + headers, + body: JSON.stringify(request) + }) + + if (!response.ok) { + throw new Error(`Chat request failed: ${response.statusText}`) + } + + if (request.stream) { + return this.parseStreamingResponse(response) + } else { + const data = ChatResponseSchema.parse(await response.json()) + return this.createAsyncIterable([data]) + } + } catch (error) { + console.error("Ollama Cloud chat request failed:", error) + throw error + } + } + + /** + * Pull a model (for cloud models, this just makes them available) + */ + async pullModel(modelName: string): Promise { + const headers: Record = { + "Content-Type": "application/json" + } + + if (this.config.apiKey) { + headers["Authorization"] = `Bearer ${this.config.apiKey}` + } + + const response = await fetch(`${this.baseUrl}/api/pull`, { + method: "POST", + headers, + body: JSON.stringify({ name: modelName }) + }) + + if (!response.ok) { + throw new Error(`Failed to pull model ${modelName}: ${response.statusText}`) + } + } + + /** + * Parse streaming response + */ + private async *parseStreamingResponse(response: Response): AsyncIterable { + if (!response.body) { + throw new Error("Response body is missing") + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const lines = decoder.decode(value, { stream: true }).split('\n').filter(line => line.trim()) + + for (const line of lines) { + try { + const data = JSON.parse(line) + const chatResponse = ChatResponseSchema.parse(data) + yield chatResponse + + if (chatResponse.done) { + return + } + } catch (parseError) { + // Skip invalid JSON lines + console.warn("Failed to parse streaming line:", line, parseError) + } + } + } + } finally { + reader.releaseLock() + } + } + + /** + * Create async iterable from array + */ + private async *createAsyncIterable(items: T[]): AsyncIterable { + for (const item of items) { + yield item + } + } + + /** + * Make authenticated request to API + */ + private async makeRequest(endpoint: string, options: RequestInit): Promise { + const url = `${this.baseUrl}${endpoint}` + + const headers: Record = { + ...options.headers as Record + } + + // Add authorization header if API key is provided + if (this.config.apiKey) { + headers["Authorization"] = `Bearer ${this.config.apiKey}` + } + + return fetch(url, { + ...options, + headers + }) + } + + /** + * Get cloud-specific models (models ending with -cloud) + */ + async getCloudModels(): Promise { + const allModels = await this.listModels() + return allModels.filter(model => model.name.endsWith("-cloud")) + } + + /** + * Validate API key format + */ + static validateApiKey(apiKey: string): boolean { + return typeof apiKey === "string" && apiKey.length > 0 + } + + /** + * Get available cloud model names + */ + async getCloudModelNames(): Promise { + const cloudModels = await this.getCloudModels() + return cloudModels.map(model => model.name) + } +} + +// Default cloud models based on Ollama documentation +export const DEFAULT_CLOUD_MODELS = [ + "gpt-oss:120b-cloud", + "llama3.1:70b-cloud", + "llama3.1:8b-cloud", + "qwen2.5:32b-cloud", + "qwen2.5:7b-cloud" +] as const + +export type CloudModelName = typeof DEFAULT_CLOUD_MODELS[number] \ No newline at end of file diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index eb57fb0..bf3b932 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -18,6 +18,8 @@ import { registerFilesystemRoutes } from "./routes/filesystem" import { registerMetaRoutes } from "./routes/meta" import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" +import { registerOllamaRoutes } from "./routes/ollama" +import { registerQwenRoutes } from "./routes/qwen" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" @@ -110,6 +112,8 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, workspaceManager: deps.workspaceManager, }) + registerOllamaRoutes(app, { logger: deps.logger }) + registerQwenRoutes(app, { logger: deps.logger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) diff --git a/packages/server/src/server/routes/ollama.ts b/packages/server/src/server/routes/ollama.ts new file mode 100644 index 0000000..f5269bd --- /dev/null +++ b/packages/server/src/server/routes/ollama.ts @@ -0,0 +1,224 @@ +import { FastifyInstance, FastifyReply } from "fastify" +import { OllamaCloudClient, type OllamaCloudConfig, type ChatRequest } from "../../integrations/ollama-cloud" +import { Logger } from "../../logger" + +interface OllamaRouteDeps { + logger: Logger +} + +export async function registerOllamaRoutes( + app: FastifyInstance, + deps: OllamaRouteDeps +) { + const logger = deps.logger.child({ component: "ollama-routes" }) + + // Get Ollama Cloud configuration + app.get('/api/ollama/config', async (request, reply) => { + try { + const config = getOllamaConfig() + return { config: { ...config, apiKey: config.apiKey ? '***' : undefined } } + } catch (error) { + logger.error({ error }, "Failed to get Ollama config") + return reply.status(500).send({ error: "Failed to get Ollama configuration" }) + } + }) + + // Update Ollama Cloud configuration + app.post('/api/ollama/config', { + schema: { + type: 'object', + required: ['enabled'], + properties: { + enabled: { type: 'boolean' }, + apiKey: { type: 'string' }, + endpoint: { type: 'string' } + } + } + }, async (request, reply) => { + try { + const { enabled, apiKey, endpoint } = request.body as any + updateOllamaConfig({ enabled, apiKey, endpoint }) + logger.info("Ollama Cloud configuration updated") + return { success: true, config: { enabled, endpoint, apiKey: apiKey ? '***' : undefined } } + } catch (error) { + logger.error({ error }, "Failed to update Ollama config") + return reply.status(500).send({ error: "Failed to update Ollama configuration" }) + } + }) + + // Test Ollama Cloud connection + app.post('/api/ollama/test', async (request, reply) => { + try { + const config = getOllamaConfig() + if (!config.enabled) { + return reply.status(400).send({ error: "Ollama Cloud is not enabled" }) + } + + const client = new OllamaCloudClient(config) + const isConnected = await client.testConnection() + + return { connected: isConnected } + } catch (error) { + logger.error({ error }, "Ollama Cloud connection test failed") + return reply.status(500).send({ error: "Connection test failed" }) + } + }) + + // List available models + app.get('/api/ollama/models', async (request, reply) => { + try { + const config = getOllamaConfig() + if (!config.enabled) { + return reply.status(400).send({ error: "Ollama Cloud is not enabled" }) + } + + const client = new OllamaCloudClient(config) + const models = await client.listModels() + + return { models } + } catch (error) { + logger.error({ error }, "Failed to list Ollama models") + return reply.status(500).send({ error: "Failed to list models" }) + } + }) + + // Get cloud models only + app.get('/api/ollama/models/cloud', async (request, reply) => { + try { + const config = getOllamaConfig() + if (!config.enabled) { + return reply.status(400).send({ error: "Ollama Cloud is not enabled" }) + } + + const client = new OllamaCloudClient(config) + const cloudModels = await client.getCloudModels() + + return { models: cloudModels } + } catch (error) { + logger.error({ error }, "Failed to list cloud models") + return reply.status(500).send({ error: "Failed to list cloud models" }) + } + }) + + // Chat completion endpoint + app.post('/api/ollama/chat', { + schema: { + type: 'object', + required: ['model', 'messages'], + properties: { + model: { type: 'string' }, + messages: { + type: 'array', + items: { + type: 'object', + required: ['role', 'content'], + properties: { + role: { type: 'string', enum: ['user', 'assistant', 'system'] }, + content: { type: 'string' } + } + } + }, + stream: { type: 'boolean' }, + options: { + type: 'object', + properties: { + temperature: { type: 'number', minimum: 0, maximum: 2 }, + top_p: { type: 'number', minimum: 0, maximum: 1 } + } + } + } + } + }, async (request, reply) => { + try { + const config = getOllamaConfig() + if (!config.enabled) { + return reply.status(400).send({ error: "Ollama Cloud is not enabled" }) + } + + const client = new OllamaCloudClient(config) + const chatRequest = request.body as ChatRequest + + // Set appropriate headers for streaming + if (chatRequest.stream) { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }) + + try { + const stream = await client.chat(chatRequest) + + for await (const chunk of stream) { + reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`) + + if (chunk.done) { + reply.raw.write('data: [DONE]\n\n') + break + } + } + + reply.raw.end() + } catch (streamError) { + logger.error({ error: streamError }, "Streaming failed") + reply.raw.end() + } + } else { + const response = await client.chat(chatRequest) + return response + } + } catch (error) { + logger.error({ error }, "Ollama chat request failed") + return reply.status(500).send({ error: "Chat request failed" }) + } + }) + + // Pull model endpoint + app.post('/api/ollama/pull', { + schema: { + type: 'object', + required: ['model'], + properties: { + model: { type: 'string' } + } + } + }, async (request, reply) => { + try { + const config = getOllamaConfig() + if (!config.enabled) { + return reply.status(400).send({ error: "Ollama Cloud is not enabled" }) + } + + const client = new OllamaCloudClient(config) + const { model } = request.body as any + + // Start async pull operation + client.pullModel(model).catch(error => { + logger.error({ error, model }, "Failed to pull model") + }) + + return { message: `Started pulling model: ${model}` } + } catch (error) { + logger.error({ error }, "Failed to initiate model pull") + return reply.status(500).send({ error: "Failed to start model pull" }) + } + }) + + logger.info("Ollama Cloud routes registered") +} + +// Configuration management functions +function getOllamaConfig(): OllamaCloudConfig { + try { + const stored = localStorage.getItem('ollama_cloud_config') + return stored ? JSON.parse(stored) : { enabled: false, endpoint: "https://ollama.com" } + } catch { + return { enabled: false, endpoint: "https://ollama.com" } + } +} + +function updateOllamaConfig(config: Partial): void { + const current = getOllamaConfig() + const updated = { ...current, ...config } + localStorage.setItem('ollama_cloud_config', JSON.stringify(updated)) +} \ No newline at end of file diff --git a/packages/server/src/server/routes/qwen.ts b/packages/server/src/server/routes/qwen.ts new file mode 100644 index 0000000..60ed957 --- /dev/null +++ b/packages/server/src/server/routes/qwen.ts @@ -0,0 +1,134 @@ +import { FastifyInstance, FastifyReply } from "fastify" +import { Logger } from "../../logger" + +interface QwenRouteDeps { + logger: Logger +} + +export async function registerQwenRoutes( + app: FastifyInstance, + deps: QwenRouteDeps +) { + const logger = deps.logger.child({ component: "qwen-routes" }) + + // Get OAuth URL for Qwen authentication + app.get('/api/qwen/oauth/url', async (request, reply) => { + try { + const { clientId, redirectUri } = request.query as any + + if (!clientId) { + return reply.status(400).send({ error: "Client ID is required" }) + } + + const authUrl = new URL('https://qwen.ai/oauth/authorize') + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('client_id', clientId) + authUrl.searchParams.set('redirect_uri', redirectUri || `${request.protocol}//${request.host}/auth/qwen/callback`) + authUrl.searchParams.set('scope', 'read write') + authUrl.searchParams.set('state', generateState()) + + return { authUrl: authUrl.toString() } + } catch (error) { + logger.error({ error }, "Failed to generate OAuth URL") + return reply.status(500).send({ error: "Failed to generate OAuth URL" }) + } + }) + + // Exchange authorization code for token + app.post('/api/qwen/oauth/exchange', { + schema: { + type: 'object', + required: ['code', 'state'], + properties: { + code: { type: 'string' }, + state: { type: 'string' }, + client_id: { type: 'string' }, + redirect_uri: { type: 'string' } + } + } + }, async (request, reply) => { + try { + const { code, state, client_id, redirect_uri } = request.body as any + + // Exchange code for token with Qwen + const tokenResponse = await fetch('https://qwen.ai/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: client_id, + code, + redirect_uri: redirect_uri + }) + }) + + if (!tokenResponse.ok) { + throw new Error(`Token exchange failed: ${tokenResponse.statusText}`) + } + + const tokenData = await tokenResponse.json() + + // Get user info + const userResponse = await fetch('https://qwen.ai/api/user', { + headers: { + 'Authorization': `Bearer ${tokenData.access_token}` + } + }) + + if (!userResponse.ok) { + throw new Error(`Failed to fetch user info: ${userResponse.statusText}`) + } + + const userData = await userResponse.json() + + return { + success: true, + user: userData, + token: { + access_token: tokenData.access_token, + token_type: tokenData.token_type, + expires_in: tokenData.expires_in, + scope: tokenData.scope + } + } + } catch (error) { + logger.error({ error }, "Qwen OAuth token exchange failed") + return reply.status(500).send({ error: "OAuth exchange failed" }) + } + }) + + // Get user info + app.get('/api/qwen/user', async (request, reply) => { + try { + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return reply.status(401).send({ error: "Authorization required" }) + } + + const token = authHeader.substring(7) + const userResponse = await fetch('https://qwen.ai/api/user', { + headers: { + 'Authorization': `Bearer ${token}` + } + }) + + if (!userResponse.ok) { + return reply.status(401).send({ error: "Invalid token" }) + } + + const userData = await userResponse.json() + return { user: userData } + } catch (error) { + logger.error({ error }, "Failed to fetch Qwen user info") + return reply.status(500).send({ error: "Failed to fetch user info" }) + } + }) + + logger.info("Qwen OAuth routes registered") +} + +function generateState(): string { + return Math.random().toString(36).substring(2, 15) + Date.now().toString(36) +} \ No newline at end of file diff --git a/packages/server/src/server/routes/storage.ts b/packages/server/src/server/routes/storage.ts index a2a874e..4b68322 100644 --- a/packages/server/src/server/routes/storage.ts +++ b/packages/server/src/server/routes/storage.ts @@ -3,7 +3,7 @@ import { z } from "zod" import { InstanceStore } from "../../storage/instance-store" import { EventBus } from "../../events/bus" import { ModelPreferenceSchema } from "../../config/schema" -import type { InstanceData } from "../../api-types" +import type { InstanceData, Task, SessionTasks } from "../../api-types" import { WorkspaceManager } from "../../workspaces/manager" interface RouteDeps { @@ -12,14 +12,24 @@ interface RouteDeps { workspaceManager: WorkspaceManager } +const TaskSchema = z.object({ + id: z.string(), + title: z.string(), + status: z.enum(["completed", "interrupted", "in-progress", "pending"]), + timestamp: z.number(), + messageIds: z.array(z.string()).optional(), +}) + const InstanceDataSchema = z.object({ messageHistory: z.array(z.string()).default([]), agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}), + sessionTasks: z.record(z.string(), z.array(TaskSchema)).optional(), }) const EMPTY_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {}, + sessionTasks: {}, } export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) { diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index effc85b..1541475 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { }) app.post("/api/workspaces", async (request, reply) => { - const body = WorkspaceCreateSchema.parse(request.body ?? {}) - const workspace = await deps.workspaceManager.create(body.path, body.name) - reply.code(201) - return workspace + try { + const body = WorkspaceCreateSchema.parse(request.body ?? {}) + const workspace = await deps.workspaceManager.create(body.path, body.name) + reply.code(201) + return workspace + } catch (error) { + request.log.error({ err: error }, "Failed to create workspace") + const message = error instanceof Error ? error.message : "Failed to create workspace" + reply.code(400).type("text/plain").send(message) + } }) app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => { diff --git a/packages/server/src/storage/instance-store.ts b/packages/server/src/storage/instance-store.ts index 4855084..948c4f9 100644 --- a/packages/server/src/storage/instance-store.ts +++ b/packages/server/src/storage/instance-store.ts @@ -7,6 +7,7 @@ import type { InstanceData } from "../api-types" const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {}, + sessionTasks: {}, } export class InstanceStore { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 404e3a1..f0d0a7e 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,5 +1,6 @@ import path from "path" import { spawnSync } from "child_process" +import { connect } from "net" import { EventBus } from "../events/bus" import { ConfigStore } from "../config/store" import { BinaryRegistry } from "../config/binaries" @@ -7,8 +8,11 @@ import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" -import { WorkspaceRuntime } from "./runtime" +import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" +import { getOpencodeConfigDir } from "../opencode-config" + +const STARTUP_STABILITY_DELAY_MS = 1500 interface WorkspaceManagerOptions { rootDir: string @@ -23,9 +27,11 @@ interface WorkspaceRecord extends WorkspaceDescriptor {} export class WorkspaceManager { private readonly workspaces = new Map() private readonly runtime: WorkspaceRuntime + private readonly opencodeConfigDir: string constructor(private readonly options: WorkspaceManagerOptions) { this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger) + this.opencodeConfigDir = getOpencodeConfigDir() } list(): WorkspaceDescriptor[] { @@ -97,10 +103,15 @@ export class WorkspaceManager { this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) - const environment = this.options.configStore.get().preferences.environmentVariables ?? {} + const preferences = this.options.configStore.get().preferences ?? {} + const userEnvironment = preferences.environmentVariables ?? {} + const environment = { + ...userEnvironment, + OPENCODE_CONFIG_DIR: this.opencodeConfigDir, + } try { - const { pid, port } = await this.runtime.launch({ + const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, binaryPath: resolvedBinaryPath, @@ -108,6 +119,8 @@ export class WorkspaceManager { onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) + await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) + descriptor.pid = pid descriptor.port = port descriptor.status = "ready" @@ -117,11 +130,20 @@ export class WorkspaceManager { return descriptor } catch (error) { descriptor.status = "error" - descriptor.error = error instanceof Error ? error.message : String(error) + let errorMessage = error instanceof Error ? error.message : String(error) + + // Check for common OpenCode issues + if (errorMessage.includes('ENOENT') || errorMessage.includes('command not found')) { + errorMessage = `OpenCode binary not found at '${resolvedBinaryPath}'. Please install OpenCode CLI from https://opencode.ai/ and ensure it's in your PATH.` + } else if (errorMessage.includes('health check')) { + errorMessage = `Workspace health check failed. OpenCode started but is not responding correctly. Check OpenCode logs for details.` + } + + descriptor.error = errorMessage descriptor.updatedAt = new Date().toISOString() this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor }) this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start") - throw error + throw new Error(errorMessage) } } @@ -233,6 +255,200 @@ export class WorkspaceManager { return undefined } + private async waitForWorkspaceReadiness(params: { + workspaceId: string + port: number + exitPromise: Promise + getLastOutput: () => string + }) { + + await Promise.race([ + this.waitForPortAvailability(params.port), + params.exitPromise.then((info) => { + throw this.buildStartupError( + params.workspaceId, + "exited before becoming ready", + info, + params.getLastOutput(), + ) + }), + ]) + + await this.waitForInstanceHealth(params) + + await Promise.race([ + this.delay(STARTUP_STABILITY_DELAY_MS), + params.exitPromise.then((info) => { + throw this.buildStartupError( + params.workspaceId, + "exited shortly after start", + info, + params.getLastOutput(), + ) + }), + ]) + } + + private async waitForInstanceHealth(params: { + workspaceId: string + port: number + exitPromise: Promise + getLastOutput: () => string + }) { + const probeResult = await Promise.race([ + this.probeInstance(params.workspaceId, params.port), + params.exitPromise.then((info) => { + throw this.buildStartupError( + params.workspaceId, + "exited during health checks", + info, + params.getLastOutput(), + ) + }), + ]) + + if (probeResult.ok) { + return + } + + const latestOutput = params.getLastOutput().trim() + const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : "" + const reason = probeResult.reason ?? "Health check failed" + throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`) + } + + private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> { + // Try multiple possible health check endpoints + const endpoints = [ + `/project/current`, + `/health`, + `/status`, + `/`, + `/api/health` + ] + + this.options.logger.info({ workspaceId, port, endpoints }, "Starting health check probe") + + for (const endpoint of endpoints) { + const url = `http://127.0.0.1:${port}${endpoint}` + + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout + + const response = await fetch(url, { + method: 'GET', + headers: { + 'User-Agent': 'NomadArch-HealthCheck/1.0' + }, + signal: controller.signal + }) + + clearTimeout(timeoutId) + + this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe response received") + + if (response.ok) { + this.options.logger.info({ workspaceId, port, endpoint }, "Health check passed") + return { ok: true } + } else { + this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe endpoint returned error") + } + } catch (error) { + this.options.logger.debug({ workspaceId, port, err: error, url, endpoint }, "Health probe endpoint failed") + // Continue to next endpoint + } + } + + // All endpoints failed + const reason = `OpenCode server started but is not responding to any known health endpoints (/project/current, /health, /status, /, /api/health)` + this.options.logger.error({ workspaceId, port }, "All health check endpoints failed") + return { ok: false, reason } + } + + private buildStartupError( + workspaceId: string, + phase: string, + exitInfo: ProcessExitInfo, + lastOutput: string, + ): Error { + const exitDetails = this.describeExit(exitInfo) + const trimmedOutput = lastOutput.trim() + const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : "" + return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`) + } + + private waitForPortAvailability(port: number, timeoutMs = 5000): Promise { + this.options.logger.info({ port, timeoutMs }, "Waiting for port availability - STARTING") + + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs + let settled = false + let retryTimer: NodeJS.Timeout | null = null + let attemptCount = 0 + + const cleanup = () => { + settled = true + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + } + + const tryConnect = () => { + if (settled) { + return + } + + attemptCount++ + this.options.logger.debug({ port, attempt: attemptCount, timeRemaining: Math.max(0, deadline - Date.now()) }, "Attempting to connect to workspace port") + + const socket = connect({ port, host: "127.0.0.1" }, () => { + this.options.logger.info({ port, attempt: attemptCount }, "Port is available - SUCCESS") + cleanup() + socket.end() + resolve() + }) + socket.once("error", (error) => { + this.options.logger.debug({ port, attempt: attemptCount, err: error instanceof Error ? error.message : String(error) }, "Port connection failed - retrying") + socket.destroy() + if (settled) { + return + } + if (Date.now() >= deadline) { + this.options.logger.error({ port, attempt: attemptCount, timeoutMs }, "Port did not become available - TIMEOUT") + cleanup() + reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`)) + } else { + retryTimer = setTimeout(() => { + retryTimer = null + tryConnect() + }, 100) + } + }) + } + + tryConnect() + }) + } + + private delay(durationMs: number): Promise { + if (durationMs <= 0) { + return Promise.resolve() + } + return new Promise((resolve) => setTimeout(resolve, durationMs)) + } + + private describeExit(info: ProcessExitInfo): string { + if (info.signal) { + return `signal ${info.signal}` + } + if (info.code !== null) { + return `code ${info.code}` + } + return "unknown reason" + } + private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { const workspace = this.workspaces.get(workspaceId) if (!workspace) return diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index b977c11..a06137a 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -1,5 +1,5 @@ import { ChildProcess, spawn } from "child_process" -import { existsSync, statSync } from "fs" +import { existsSync, statSync, accessSync, constants } from "fs" import path from "path" import { EventBus } from "../events/bus" import { LogLevel, WorkspaceLogEntry } from "../api-types" @@ -13,7 +13,7 @@ interface LaunchOptions { onExit?: (info: ProcessExitInfo) => void } -interface ProcessExitInfo { +export interface ProcessExitInfo { workspaceId: string code: number | null signal: NodeJS.Signals | null @@ -30,11 +30,35 @@ export class WorkspaceRuntime { constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {} - async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> { + async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise; getLastOutput: () => string }> { this.validateFolder(options.folder) + + // Check if binary exists before attempting to launch + try { + accessSync(options.binaryPath, constants.F_OK) + } catch (error) { + throw new Error(`OpenCode binary not found: ${options.binaryPath}. Please install OpenCode CLI from https://opencode.ai/ and ensure it's in your PATH.`) + } const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] - const env = { ...process.env, ...(options.environment ?? {}) } + const env = { + ...process.env, + ...(options.environment ?? {}), + "OPENCODE_SERVER_HOST": "127.0.0.1", + "OPENCODE_SERVER_PORT": "0", + "NODE_ENV": "production" + } + + this.logger.info( + { workspaceId: options.workspaceId, binaryPath: options.binaryPath, args }, + "Starting OpenCode with arguments" + ) + + let exitResolve: ((info: ProcessExitInfo) => void) | null = null + const exitPromise = new Promise((resolveExit) => { + exitResolve = resolveExit + }) + let lastOutput = "" return new Promise((resolve, reject) => { this.logger.info( @@ -83,11 +107,21 @@ export class WorkspaceRuntime { cleanupStreams() child.removeListener("error", handleError) child.removeListener("exit", handleExit) + const exitInfo: ProcessExitInfo = { + workspaceId: options.workspaceId, + code, + signal, + requested: managed.requestedStop, + } + if (exitResolve) { + exitResolve(exitInfo) + exitResolve = null + } if (!portFound) { const reason = stderrBuffer || `Process exited with code ${code}` reject(new Error(reason)) } else { - options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop }) + options.onExit?.(exitInfo) } } @@ -96,6 +130,10 @@ export class WorkspaceRuntime { child.removeListener("exit", handleExit) this.processes.delete(options.workspaceId) this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error") + if (exitResolve) { + exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop }) + exitResolve = null + } reject(error) } @@ -109,18 +147,28 @@ export class WorkspaceRuntime { stdoutBuffer = lines.pop() ?? "" for (const line of lines) { - if (!line.trim()) continue + const trimmed = line.trim() + if (!trimmed) continue + lastOutput = trimmed this.emitLog(options.workspaceId, "info", line) if (!portFound) { - const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) + this.logger.debug({ workspaceId: options.workspaceId, line: trimmed }, "OpenCode output line") + // Try multiple patterns for port detection + const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) || + line.match(/server listening on http:\/\/.+:(\d+)/i) || + line.match(/listening on http:\/\/.+:(\d+)/i) || + line.match(/:(\d+)/i) + if (portMatch) { portFound = true - cleanupStreams() child.removeListener("error", handleError) const port = parseInt(portMatch[1], 10) - this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port") - resolve({ pid: child.pid!, port }) + this.logger.info({ workspaceId: options.workspaceId, port, matchedLine: trimmed }, "Workspace runtime allocated port - PORT DETECTED") + const getLastOutput = () => lastOutput.trim() + resolve({ pid: child.pid!, port, exitPromise, getLastOutput }) + } else { + this.logger.debug({ workspaceId: options.workspaceId, line: trimmed }, "Port detection - no match in this line") } } } @@ -133,7 +181,9 @@ export class WorkspaceRuntime { stderrBuffer = lines.pop() ?? "" for (const line of lines) { - if (!line.trim()) continue + const trimmed = line.trim() + if (!trimmed) continue + lastOutput = `[stderr] ${trimmed}` this.emitLog(options.workspaceId, "error", line) } }) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index cc1b876..e7fa874 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2" import { RemoteAccessOverlay } from "./components/remote-access-overlay" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { initMarkdown } from "./lib/markdown" +import QwenOAuthCallback from "./pages/QwenOAuthCallback" import { useTheme } from "./lib/theme" import { useCommands } from "./lib/hooks/use-commands" @@ -21,11 +22,9 @@ import { hasInstances, isSelectingFolder, setIsSelectingFolder, - setHasInstances, showFolderSelection, setShowFolderSelection, } from "./stores/ui" -import { instances as instanceStore } from "./stores/instances" import { useConfig } from "./stores/preferences" import { createInstance, @@ -65,7 +64,12 @@ const App: Component = () => { setThinkingBlocksExpansion, } = useConfig() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) - const [launchErrorBinary, setLaunchErrorBinary] = createSignal(null) + interface LaunchErrorState { + message: string + binaryPath: string + missingBinary: boolean + } + const [launchError, setLaunchError] = createSignal(null) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) @@ -105,14 +109,30 @@ const App: Component = () => { }) const launchErrorPath = () => { - const value = launchErrorBinary() + const value = launchError()?.binaryPath if (!value) return "opencode" return value.trim() || "opencode" } - const isMissingBinaryError = (error: unknown): boolean => { - if (!error) return false - const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error) + const launchErrorMessage = () => launchError()?.message ?? "" + + const formatLaunchErrorMessage = (error: unknown): string => { + if (!error) { + return "Failed to launch workspace" + } + const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error) + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed.error === "string") { + return parsed.error + } + } catch { + // ignore JSON parse errors + } + return raw + } + + const isMissingBinaryMessage = (message: string): boolean => { const normalized = message.toLowerCase() return ( normalized.includes("opencode binary not found") || @@ -123,7 +143,7 @@ const App: Component = () => { ) } - const clearLaunchError = () => setLaunchErrorBinary(null) + const clearLaunchError = () => setLaunchError(null) async function handleSelectFolder(folderPath: string, binaryPath?: string) { if (!folderPath) { @@ -135,7 +155,6 @@ const App: Component = () => { recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() const instanceId = await createInstance(folderPath, selectedBinary) - setHasInstances(true) setShowFolderSelection(false) setIsAdvancedSettingsOpen(false) @@ -144,10 +163,13 @@ const App: Component = () => { port: instances().get(instanceId)?.port, }) } catch (error) { - clearLaunchError() - if (isMissingBinaryError(error)) { - setLaunchErrorBinary(selectedBinary) - } + const message = formatLaunchErrorMessage(error) + const missingBinary = isMissingBinaryMessage(message) + setLaunchError({ + message, + binaryPath: selectedBinary, + missingBinary, + }) log.error("Failed to create instance", error) } finally { setIsSelectingFolder(false) @@ -191,9 +213,6 @@ const App: Component = () => { if (!confirmed) return await stopInstance(instanceId) - if (instances().size === 0) { - setHasInstances(false) - } } async function handleNewSession(instanceId: string) { @@ -295,6 +314,13 @@ const App: Component = () => { } }) + // Check if this is OAuth callback + const isOAuthCallback = window.location.pathname === '/auth/qwen/callback' + + if (isOAuthCallback) { + return + } + return ( <> { onClose={handleDisconnectedInstanceClose} /> - +
@@ -312,8 +338,8 @@ const App: Component = () => {
Unable to launch OpenCode - Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from - Advanced Settings. + We couldn't start the selected OpenCode binary. Review the error output below or choose a different + binary from Advanced Settings.
@@ -322,10 +348,23 @@ const App: Component = () => {

{launchErrorPath()}

+ +
+

Error output

+
{launchErrorMessage()}
+
+
+
- + + + diff --git a/packages/ui/src/components/advanced-settings-modal.tsx b/packages/ui/src/components/advanced-settings-modal.tsx index 06a60bb..8af70fa 100644 --- a/packages/ui/src/components/advanced-settings-modal.tsx +++ b/packages/ui/src/components/advanced-settings-modal.tsx @@ -1,7 +1,9 @@ -import { Component } from "solid-js" +import { Component, createSignal, Show } from "solid-js" import { Dialog } from "@kobalte/core/dialog" import OpenCodeBinarySelector from "./opencode-binary-selector" import EnvironmentVariablesEditor from "./environment-variables-editor" +import OllamaCloudSettings from "./settings/OllamaCloudSettings" +import QwenCodeSettings from "./settings/QwenCodeSettings" interface AdvancedSettingsModalProps { open: boolean @@ -12,35 +14,84 @@ interface AdvancedSettingsModalProps { } const AdvancedSettingsModal: Component = (props) => { + const [activeTab, setActiveTab] = createSignal("general") + return ( !open && props.onClose()}>
- +
Advanced Settings
-
- - -
-
-

Environment Variables

-

Applied whenever a new OpenCode instance starts

-
-
- -
+
+
+ + +
+
+ +
+ + +
+
+

Environment Variables

+

Applied whenever a new OpenCode instance starts

+
+
+ +
+
+
+
+ + + + + + + + +
+
+ + + +
+ + {tasks().length} +
+
+
+
+ + +
+
+ +
+ + {/* TASK LIST VIEW - CODEX 5.1 Styled */} +
+
+
+
+

Project Pipeline

+

Manage and orchestrate agentic tasks

+
+ +
+ + {/* Task List */} +
+ + {(task) => ( +
setSelectedTaskId(task.id)} + class={`p-4 rounded-xl border transition-all cursor-pointer ${ + task.id === selectedTaskId() + ? "bg-indigo-500/10 border-indigo-500/20" + : "bg-zinc-800/40 border-white/5 hover:border-indigo-500/20 hover:bg-indigo-500/5" + }`} + > +
+
+
+ {task.status === "completed" ? ( + + ) : task.status === "in-progress" ? ( + + ) : ( + + )} +
+
+

{task.title}

+

{formatTimestamp(task.timestamp)}

+
+
+ +
+
+ )} +
+
+
+
+
+ + + {/* TASK CHAT VIEW - When task is selected */} +
+
+ scrollContainer} + setBottomSentinel={setBottomSentinel} + showThinking={() => true} + thinkingDefaultExpanded={() => true} + showUsageMetrics={() => true} + /> +
+ + {/* CODEX 5.1 Chat Input Area */} +
+
+
+
+
+ +
+ TASK ASSISTANT +
+
+ + {selectedTask()?.status} + +
+
+ +
+
+