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.
This commit is contained in:
0
packages/electron-app/scripts/dev.sh
Executable file → Normal file
0
packages/electron-app/scripts/dev.sh
Executable file → Normal file
@@ -16,10 +16,11 @@
|
|||||||
"codenomad": "dist/bin.js"
|
"codenomad": "dist/bin.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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",
|
"build:ui": "npm run build --prefix ../ui",
|
||||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
"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"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ import type {
|
|||||||
RecentFolder,
|
RecentFolder,
|
||||||
} from "./config/schema"
|
} 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.
|
* Canonical HTTP/SSE contract for the CLI server.
|
||||||
* These types are consumed by both the CLI implementation and any UI clients.
|
* These types are consumed by both the CLI implementation and any UI clients.
|
||||||
@@ -109,6 +123,7 @@ export type WorkspaceFileSearchResponse = FileSystemEntry[]
|
|||||||
export interface InstanceData {
|
export interface InstanceData {
|
||||||
messageHistory: string[]
|
messageHistory: string[]
|
||||||
agentModelSelections: AgentModelSelection
|
agentModelSelections: AgentModelSelection
|
||||||
|
sessionTasks?: SessionTasks // Multi-task chat support: tasks per session
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||||
|
|||||||
273
packages/server/src/integrations/ollama-cloud.ts
Normal file
273
packages/server/src/integrations/ollama-cloud.ts
Normal file
@@ -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<typeof OllamaCloudConfigSchema>
|
||||||
|
|
||||||
|
// 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<typeof OllamaModelSchema>
|
||||||
|
|
||||||
|
// 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<typeof ChatMessageSchema>
|
||||||
|
|
||||||
|
// 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<typeof ChatRequestSchema>
|
||||||
|
export type ChatResponse = z.infer<typeof ChatResponseSchema>
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<OllamaModel[]> {
|
||||||
|
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<AsyncIterable<ChatResponse>> {
|
||||||
|
if (!this.config.apiKey) {
|
||||||
|
throw new Error("Ollama Cloud API key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"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<void> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"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<ChatResponse> {
|
||||||
|
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<T>(items: T[]): AsyncIterable<T> {
|
||||||
|
for (const item of items) {
|
||||||
|
yield item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make authenticated request to API
|
||||||
|
*/
|
||||||
|
private async makeRequest(endpoint: string, options: RequestInit): Promise<Response> {
|
||||||
|
const url = `${this.baseUrl}${endpoint}`
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...options.headers as Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<OllamaModel[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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]
|
||||||
@@ -18,6 +18,8 @@ import { registerFilesystemRoutes } from "./routes/filesystem"
|
|||||||
import { registerMetaRoutes } from "./routes/meta"
|
import { registerMetaRoutes } from "./routes/meta"
|
||||||
import { registerEventRoutes } from "./routes/events"
|
import { registerEventRoutes } from "./routes/events"
|
||||||
import { registerStorageRoutes } from "./routes/storage"
|
import { registerStorageRoutes } from "./routes/storage"
|
||||||
|
import { registerOllamaRoutes } from "./routes/ollama"
|
||||||
|
import { registerQwenRoutes } from "./routes/qwen"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
|
|
||||||
@@ -110,6 +112,8 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
|
registerOllamaRoutes(app, { logger: deps.logger })
|
||||||
|
registerQwenRoutes(app, { logger: deps.logger })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
224
packages/server/src/server/routes/ollama.ts
Normal file
224
packages/server/src/server/routes/ollama.ts
Normal file
@@ -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<OllamaCloudConfig>): void {
|
||||||
|
const current = getOllamaConfig()
|
||||||
|
const updated = { ...current, ...config }
|
||||||
|
localStorage.setItem('ollama_cloud_config', JSON.stringify(updated))
|
||||||
|
}
|
||||||
134
packages/server/src/server/routes/qwen.ts
Normal file
134
packages/server/src/server/routes/qwen.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { z } from "zod"
|
|||||||
import { InstanceStore } from "../../storage/instance-store"
|
import { InstanceStore } from "../../storage/instance-store"
|
||||||
import { EventBus } from "../../events/bus"
|
import { EventBus } from "../../events/bus"
|
||||||
import { ModelPreferenceSchema } from "../../config/schema"
|
import { ModelPreferenceSchema } from "../../config/schema"
|
||||||
import type { InstanceData } from "../../api-types"
|
import type { InstanceData, Task, SessionTasks } from "../../api-types"
|
||||||
import { WorkspaceManager } from "../../workspaces/manager"
|
import { WorkspaceManager } from "../../workspaces/manager"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
@@ -12,14 +12,24 @@ interface RouteDeps {
|
|||||||
workspaceManager: WorkspaceManager
|
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({
|
const InstanceDataSchema = z.object({
|
||||||
messageHistory: z.array(z.string()).default([]),
|
messageHistory: z.array(z.string()).default([]),
|
||||||
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
|
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
|
||||||
|
sessionTasks: z.record(z.string(), z.array(TaskSchema)).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const EMPTY_INSTANCE_DATA: InstanceData = {
|
const EMPTY_INSTANCE_DATA: InstanceData = {
|
||||||
messageHistory: [],
|
messageHistory: [],
|
||||||
agentModelSelections: {},
|
agentModelSelections: {},
|
||||||
|
sessionTasks: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
|||||||
@@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/workspaces", async (request, reply) => {
|
app.post("/api/workspaces", async (request, reply) => {
|
||||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
try {
|
||||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||||
reply.code(201)
|
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||||
return workspace
|
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) => {
|
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { InstanceData } from "../api-types"
|
|||||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||||
messageHistory: [],
|
messageHistory: [],
|
||||||
agentModelSelections: {},
|
agentModelSelections: {},
|
||||||
|
sessionTasks: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstanceStore {
|
export class InstanceStore {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
|
import { connect } from "net"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { ConfigStore } from "../config/store"
|
import { ConfigStore } from "../config/store"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
@@ -7,8 +8,11 @@ import { FileSystemBrowser } from "../filesystem/browser"
|
|||||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||||
import { WorkspaceRuntime } from "./runtime"
|
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
import { getOpencodeConfigDir } from "../opencode-config"
|
||||||
|
|
||||||
|
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
@@ -23,9 +27,11 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
|
|||||||
export class WorkspaceManager {
|
export class WorkspaceManager {
|
||||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||||
private readonly runtime: WorkspaceRuntime
|
private readonly runtime: WorkspaceRuntime
|
||||||
|
private readonly opencodeConfigDir: string
|
||||||
|
|
||||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||||
|
this.opencodeConfigDir = getOpencodeConfigDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
list(): WorkspaceDescriptor[] {
|
list(): WorkspaceDescriptor[] {
|
||||||
@@ -97,10 +103,15 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
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 {
|
try {
|
||||||
const { pid, port } = await this.runtime.launch({
|
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
folder: workspacePath,
|
folder: workspacePath,
|
||||||
binaryPath: resolvedBinaryPath,
|
binaryPath: resolvedBinaryPath,
|
||||||
@@ -108,6 +119,8 @@ export class WorkspaceManager {
|
|||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||||
|
|
||||||
descriptor.pid = pid
|
descriptor.pid = pid
|
||||||
descriptor.port = port
|
descriptor.port = port
|
||||||
descriptor.status = "ready"
|
descriptor.status = "ready"
|
||||||
@@ -117,11 +130,20 @@ export class WorkspaceManager {
|
|||||||
return descriptor
|
return descriptor
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
descriptor.status = "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()
|
descriptor.updatedAt = new Date().toISOString()
|
||||||
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
||||||
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForWorkspaceReadiness(params: {
|
||||||
|
workspaceId: string
|
||||||
|
port: number
|
||||||
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
|
getLastOutput: () => string
|
||||||
|
}) {
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
this.waitForPortAvailability(params.port),
|
||||||
|
params.exitPromise.then((info) => {
|
||||||
|
throw this.buildStartupError(
|
||||||
|
params.workspaceId,
|
||||||
|
"exited before becoming ready",
|
||||||
|
info,
|
||||||
|
params.getLastOutput(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
await this.waitForInstanceHealth(params)
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||||
|
params.exitPromise.then((info) => {
|
||||||
|
throw this.buildStartupError(
|
||||||
|
params.workspaceId,
|
||||||
|
"exited shortly after start",
|
||||||
|
info,
|
||||||
|
params.getLastOutput(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForInstanceHealth(params: {
|
||||||
|
workspaceId: string
|
||||||
|
port: number
|
||||||
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
|
getLastOutput: () => string
|
||||||
|
}) {
|
||||||
|
const probeResult = await Promise.race([
|
||||||
|
this.probeInstance(params.workspaceId, params.port),
|
||||||
|
params.exitPromise.then((info) => {
|
||||||
|
throw this.buildStartupError(
|
||||||
|
params.workspaceId,
|
||||||
|
"exited during health checks",
|
||||||
|
info,
|
||||||
|
params.getLastOutput(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (probeResult.ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestOutput = params.getLastOutput().trim()
|
||||||
|
const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : ""
|
||||||
|
const reason = probeResult.reason ?? "Health check failed"
|
||||||
|
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||||
|
// Try multiple possible health check endpoints
|
||||||
|
const endpoints = [
|
||||||
|
`/project/current`,
|
||||||
|
`/health`,
|
||||||
|
`/status`,
|
||||||
|
`/`,
|
||||||
|
`/api/health`
|
||||||
|
]
|
||||||
|
|
||||||
|
this.options.logger.info({ workspaceId, port, endpoints }, "Starting health check probe")
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const url = `http://127.0.0.1:${port}${endpoint}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'NomadArch-HealthCheck/1.0'
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe response received")
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.options.logger.info({ workspaceId, port, endpoint }, "Health check passed")
|
||||||
|
return { ok: true }
|
||||||
|
} else {
|
||||||
|
this.options.logger.debug({ workspaceId, status: response.status, url, endpoint }, "Health probe endpoint returned error")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.debug({ workspaceId, port, err: error, url, endpoint }, "Health probe endpoint failed")
|
||||||
|
// Continue to next endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All endpoints failed
|
||||||
|
const reason = `OpenCode server started but is not responding to any known health endpoints (/project/current, /health, /status, /, /api/health)`
|
||||||
|
this.options.logger.error({ workspaceId, port }, "All health check endpoints failed")
|
||||||
|
return { ok: false, reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStartupError(
|
||||||
|
workspaceId: string,
|
||||||
|
phase: string,
|
||||||
|
exitInfo: ProcessExitInfo,
|
||||||
|
lastOutput: string,
|
||||||
|
): Error {
|
||||||
|
const exitDetails = this.describeExit(exitInfo)
|
||||||
|
const trimmedOutput = lastOutput.trim()
|
||||||
|
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
|
||||||
|
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
|
||||||
|
this.options.logger.info({ port, timeoutMs }, "Waiting for port availability - STARTING")
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
let settled = false
|
||||||
|
let retryTimer: NodeJS.Timeout | null = null
|
||||||
|
let attemptCount = 0
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
settled = true
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryConnect = () => {
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptCount++
|
||||||
|
this.options.logger.debug({ port, attempt: attemptCount, timeRemaining: Math.max(0, deadline - Date.now()) }, "Attempting to connect to workspace port")
|
||||||
|
|
||||||
|
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||||
|
this.options.logger.info({ port, attempt: attemptCount }, "Port is available - SUCCESS")
|
||||||
|
cleanup()
|
||||||
|
socket.end()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
socket.once("error", (error) => {
|
||||||
|
this.options.logger.debug({ port, attempt: attemptCount, err: error instanceof Error ? error.message : String(error) }, "Port connection failed - retrying")
|
||||||
|
socket.destroy()
|
||||||
|
if (settled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
this.options.logger.error({ port, attempt: attemptCount, timeoutMs }, "Port did not become available - TIMEOUT")
|
||||||
|
cleanup()
|
||||||
|
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
|
||||||
|
} else {
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryTimer = null
|
||||||
|
tryConnect()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tryConnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(durationMs: number): Promise<void> {
|
||||||
|
if (durationMs <= 0) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, durationMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeExit(info: ProcessExitInfo): string {
|
||||||
|
if (info.signal) {
|
||||||
|
return `signal ${info.signal}`
|
||||||
|
}
|
||||||
|
if (info.code !== null) {
|
||||||
|
return `code ${info.code}`
|
||||||
|
}
|
||||||
|
return "unknown reason"
|
||||||
|
}
|
||||||
|
|
||||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||||
const workspace = this.workspaces.get(workspaceId)
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
if (!workspace) return
|
if (!workspace) return
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ChildProcess, spawn } from "child_process"
|
import { ChildProcess, spawn } from "child_process"
|
||||||
import { existsSync, statSync } from "fs"
|
import { existsSync, statSync, accessSync, constants } from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
@@ -13,7 +13,7 @@ interface LaunchOptions {
|
|||||||
onExit?: (info: ProcessExitInfo) => void
|
onExit?: (info: ProcessExitInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessExitInfo {
|
export interface ProcessExitInfo {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
code: number | null
|
code: number | null
|
||||||
signal: NodeJS.Signals | null
|
signal: NodeJS.Signals | null
|
||||||
@@ -30,11 +30,35 @@ export class WorkspaceRuntime {
|
|||||||
|
|
||||||
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
|
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<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||||
this.validateFolder(options.folder)
|
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 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<ProcessExitInfo>((resolveExit) => {
|
||||||
|
exitResolve = resolveExit
|
||||||
|
})
|
||||||
|
let lastOutput = ""
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
@@ -83,11 +107,21 @@ export class WorkspaceRuntime {
|
|||||||
cleanupStreams()
|
cleanupStreams()
|
||||||
child.removeListener("error", handleError)
|
child.removeListener("error", handleError)
|
||||||
child.removeListener("exit", handleExit)
|
child.removeListener("exit", handleExit)
|
||||||
|
const exitInfo: ProcessExitInfo = {
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
code,
|
||||||
|
signal,
|
||||||
|
requested: managed.requestedStop,
|
||||||
|
}
|
||||||
|
if (exitResolve) {
|
||||||
|
exitResolve(exitInfo)
|
||||||
|
exitResolve = null
|
||||||
|
}
|
||||||
if (!portFound) {
|
if (!portFound) {
|
||||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||||
reject(new Error(reason))
|
reject(new Error(reason))
|
||||||
} else {
|
} 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)
|
child.removeListener("exit", handleExit)
|
||||||
this.processes.delete(options.workspaceId)
|
this.processes.delete(options.workspaceId)
|
||||||
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
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)
|
reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,18 +147,28 @@ export class WorkspaceRuntime {
|
|||||||
stdoutBuffer = lines.pop() ?? ""
|
stdoutBuffer = lines.pop() ?? ""
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
lastOutput = trimmed
|
||||||
this.emitLog(options.workspaceId, "info", line)
|
this.emitLog(options.workspaceId, "info", line)
|
||||||
|
|
||||||
if (!portFound) {
|
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) {
|
if (portMatch) {
|
||||||
portFound = true
|
portFound = true
|
||||||
cleanupStreams()
|
|
||||||
child.removeListener("error", handleError)
|
child.removeListener("error", handleError)
|
||||||
const port = parseInt(portMatch[1], 10)
|
const port = parseInt(portMatch[1], 10)
|
||||||
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
|
this.logger.info({ workspaceId: options.workspaceId, port, matchedLine: trimmed }, "Workspace runtime allocated port - PORT DETECTED")
|
||||||
resolve({ pid: child.pid!, port })
|
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() ?? ""
|
stderrBuffer = lines.pop() ?? ""
|
||||||
|
|
||||||
for (const line of lines) {
|
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)
|
this.emitLog(options.workspaceId, "error", line)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
|
|||||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
import { initMarkdown } from "./lib/markdown"
|
||||||
|
import QwenOAuthCallback from "./pages/QwenOAuthCallback"
|
||||||
|
|
||||||
import { useTheme } from "./lib/theme"
|
import { useTheme } from "./lib/theme"
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
@@ -21,11 +22,9 @@ import {
|
|||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
setIsSelectingFolder,
|
setIsSelectingFolder,
|
||||||
setHasInstances,
|
|
||||||
showFolderSelection,
|
showFolderSelection,
|
||||||
setShowFolderSelection,
|
setShowFolderSelection,
|
||||||
} from "./stores/ui"
|
} from "./stores/ui"
|
||||||
import { instances as instanceStore } from "./stores/instances"
|
|
||||||
import { useConfig } from "./stores/preferences"
|
import { useConfig } from "./stores/preferences"
|
||||||
import {
|
import {
|
||||||
createInstance,
|
createInstance,
|
||||||
@@ -65,7 +64,12 @@ const App: Component = () => {
|
|||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
interface LaunchErrorState {
|
||||||
|
message: string
|
||||||
|
binaryPath: string
|
||||||
|
missingBinary: boolean
|
||||||
|
}
|
||||||
|
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
@@ -105,14 +109,30 @@ const App: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const launchErrorPath = () => {
|
const launchErrorPath = () => {
|
||||||
const value = launchErrorBinary()
|
const value = launchError()?.binaryPath
|
||||||
if (!value) return "opencode"
|
if (!value) return "opencode"
|
||||||
return value.trim() || "opencode"
|
return value.trim() || "opencode"
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMissingBinaryError = (error: unknown): boolean => {
|
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||||
if (!error) return false
|
|
||||||
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
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()
|
const normalized = message.toLowerCase()
|
||||||
return (
|
return (
|
||||||
normalized.includes("opencode binary not found") ||
|
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) {
|
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||||
if (!folderPath) {
|
if (!folderPath) {
|
||||||
@@ -135,7 +155,6 @@ const App: Component = () => {
|
|||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
setHasInstances(true)
|
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
setIsAdvancedSettingsOpen(false)
|
setIsAdvancedSettingsOpen(false)
|
||||||
|
|
||||||
@@ -144,10 +163,13 @@ const App: Component = () => {
|
|||||||
port: instances().get(instanceId)?.port,
|
port: instances().get(instanceId)?.port,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearLaunchError()
|
const message = formatLaunchErrorMessage(error)
|
||||||
if (isMissingBinaryError(error)) {
|
const missingBinary = isMissingBinaryMessage(message)
|
||||||
setLaunchErrorBinary(selectedBinary)
|
setLaunchError({
|
||||||
}
|
message,
|
||||||
|
binaryPath: selectedBinary,
|
||||||
|
missingBinary,
|
||||||
|
})
|
||||||
log.error("Failed to create instance", error)
|
log.error("Failed to create instance", error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSelectingFolder(false)
|
setIsSelectingFolder(false)
|
||||||
@@ -191,9 +213,6 @@ const App: Component = () => {
|
|||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
await stopInstance(instanceId)
|
await stopInstance(instanceId)
|
||||||
if (instances().size === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNewSession(instanceId: string) {
|
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 <QwenOAuthCallback />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<InstanceDisconnectedModal
|
<InstanceDisconnectedModal
|
||||||
@@ -304,7 +330,7 @@ const App: Component = () => {
|
|||||||
onClose={handleDisconnectedInstanceClose}
|
onClose={handleDisconnectedInstanceClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(launchErrorBinary())} modal>
|
<Dialog open={Boolean(launchError())} modal>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
@@ -312,8 +338,8 @@ const App: Component = () => {
|
|||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
|
||||||
Advanced Settings.
|
binary from Advanced Settings.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -322,10 +348,23 @@ const App: Component = () => {
|
|||||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={launchErrorMessage()}>
|
||||||
|
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||||
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
|
||||||
|
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
|
<Show when={launchError()?.missingBinary}>
|
||||||
Open Advanced Settings
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={handleLaunchErrorAdvanced}
|
||||||
|
>
|
||||||
|
Open Advanced Settings
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Component } from "solid-js"
|
import { Component, createSignal, Show } from "solid-js"
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
||||||
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
||||||
|
import OllamaCloudSettings from "./settings/OllamaCloudSettings"
|
||||||
|
import QwenCodeSettings from "./settings/QwenCodeSettings"
|
||||||
|
|
||||||
interface AdvancedSettingsModalProps {
|
interface AdvancedSettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -12,35 +14,84 @@ interface AdvancedSettingsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
||||||
|
const [activeTab, setActiveTab] = createSignal("general")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
<Dialog.Content class="modal-surface w-full max-w-6xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
<div class="border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||||
<OpenCodeBinarySelector
|
<div class="flex w-full px-6">
|
||||||
selectedBinary={props.selectedBinary}
|
<button
|
||||||
onBinaryChange={props.onBinaryChange}
|
class={`px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:border-gray-300 ${
|
||||||
disabled={Boolean(props.isLoading)}
|
activeTab() === "general"
|
||||||
isVisible={props.open}
|
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||||
/>
|
: ""
|
||||||
|
}`}
|
||||||
<div class="panel">
|
onClick={() => setActiveTab("general")}
|
||||||
<div class="panel-header">
|
>
|
||||||
<h3 class="panel-title">Environment Variables</h3>
|
General
|
||||||
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
|
</button>
|
||||||
</div>
|
<button
|
||||||
<div class="panel-body">
|
class={`px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:border-gray-300 ${
|
||||||
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
activeTab() === "ollama"
|
||||||
</div>
|
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab("ollama")}
|
||||||
|
>
|
||||||
|
Ollama Cloud
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:border-gray-300 ${
|
||||||
|
activeTab() === "qwen"
|
||||||
|
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab("qwen")}
|
||||||
|
>
|
||||||
|
Qwen Code
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<Show when={activeTab() === "general"}>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<OpenCodeBinarySelector
|
||||||
|
selectedBinary={props.selectedBinary}
|
||||||
|
onBinaryChange={props.onBinaryChange}
|
||||||
|
disabled={Boolean(props.isLoading)}
|
||||||
|
isVisible={props.open}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Environment Variables</h3>
|
||||||
|
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeTab() === "ollama"}>
|
||||||
|
<OllamaCloudSettings />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeTab() === "qwen"}>
|
||||||
|
<QwenCodeSettings />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
334
packages/ui/src/components/chat/multi-task-chat-backup.tsx
Normal file
334
packages/ui/src/components/chat/multi-task-chat-backup.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { createSignal, createMemo, Show, For, onMount } from "solid-js";
|
||||||
|
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
|
||||||
|
import { instances } from "@/stores/instances";
|
||||||
|
import { sendMessage } from "@/stores/session-actions";
|
||||||
|
import { addTask, setActiveTask } from "@/stores/task-actions";
|
||||||
|
import { messageStoreBus } from "@/stores/message-v2/bus";
|
||||||
|
import MessageBlockList from "@/components/message-block-list";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
Plus,
|
||||||
|
CheckCircle2,
|
||||||
|
MoreHorizontal,
|
||||||
|
PanelRight,
|
||||||
|
ListTodo,
|
||||||
|
AtSign,
|
||||||
|
Hash,
|
||||||
|
Mic,
|
||||||
|
ArrowUp,
|
||||||
|
Terminal,
|
||||||
|
FileCode2,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
Code2,
|
||||||
|
} from "lucide-solid";
|
||||||
|
import type { Task, TaskStatus } from "@/types/session";
|
||||||
|
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
|
||||||
|
|
||||||
|
interface MultiTaskChatProps {
|
||||||
|
instanceId: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PanelView = "tasks" | "active";
|
||||||
|
|
||||||
|
export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||||
|
const session = () => {
|
||||||
|
const instanceSessions = sessions().get(props.instanceId);
|
||||||
|
return instanceSessions?.get(props.sessionId);
|
||||||
|
};
|
||||||
|
const selectedTaskId = () => session()?.activeTaskId || null;
|
||||||
|
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
|
||||||
|
const [isCreatingTask, setIsCreatingTask] = createSignal(false);
|
||||||
|
const [chatInput, setChatInput] = createSignal("");
|
||||||
|
const [isSending, setIsSending] = createSignal(false);
|
||||||
|
let scrollContainer: HTMLDivElement | undefined;
|
||||||
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Message store integration for chat display
|
||||||
|
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
|
||||||
|
const messageIds = () => messageStore().getSessionMessageIds(props.sessionId);
|
||||||
|
const lastAssistantIndex = () => {
|
||||||
|
const ids = messageIds();
|
||||||
|
const store = messageStore();
|
||||||
|
for (let i = ids.length - 1; i >= 0; i--) {
|
||||||
|
const msg = store.getMessage(ids[i]);
|
||||||
|
if (msg?.role === "assistant") return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle message sending with comprehensive error handling
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
const message = chatInput().trim();
|
||||||
|
if (!message || isSending()) return;
|
||||||
|
|
||||||
|
const currentInstance = instances().get(props.instanceId);
|
||||||
|
const instanceSessions = sessions().get(props.instanceId);
|
||||||
|
const currentSession = instanceSessions?.get(props.sessionId);
|
||||||
|
const sessionTasks = currentSession?.tasks || [];
|
||||||
|
const selectedTask = sessionTasks.find((task: Task) => task.id === selectedTaskId());
|
||||||
|
|
||||||
|
if (!currentInstance || !currentSession) {
|
||||||
|
console.error("[MultiTaskChat] Instance or session not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
const messageId = await sendMessage(
|
||||||
|
props.instanceId,
|
||||||
|
props.sessionId,
|
||||||
|
message,
|
||||||
|
[], // No attachments for now
|
||||||
|
selectedTask?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear input after successful send
|
||||||
|
setChatInput("");
|
||||||
|
console.log("[MultiTaskChat] Message sent successfully:", messageId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[MultiTaskChat] Failed to send message:", error);
|
||||||
|
// TODO: Show toast notification to user
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts (Cmd/Ctrl+Enter to send)
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle artifact opening via code streamer
|
||||||
|
const handleArtifactOpen = (artifact: any) => {
|
||||||
|
console.log("[MultiTaskChat] Opening artifact:", artifact);
|
||||||
|
// TODO: Implement code streamer integration
|
||||||
|
// For now, we'll log artifact and show a placeholder message
|
||||||
|
console.log(`[MultiTaskChat] Would open ${artifact.name} (${artifact.type})`);
|
||||||
|
// TODO: Show toast notification to user
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasks = () => {
|
||||||
|
const instanceSessions = sessions().get(props.instanceId);
|
||||||
|
const currentSession = instanceSessions?.get(props.sessionId);
|
||||||
|
return currentSession?.tasks || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTask = () => {
|
||||||
|
const taskTitle = `Task ${tasks().length + 1}`;
|
||||||
|
addTask(props.instanceId, props.sessionId, taskTitle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: number) => {
|
||||||
|
return new Date(timestamp).toLocaleTimeString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedTask = () => {
|
||||||
|
const instanceSessions = sessions().get(props.instanceId);
|
||||||
|
const currentSession = instanceSessions?.get(props.sessionId);
|
||||||
|
const sessionTasks = currentSession?.tasks || [];
|
||||||
|
return sessionTasks.find(task => task.id === selectedTaskId());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-full flex flex-col bg-[#0a0a0b]">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="h-12 px-4 flex items-center justify-between bg-zinc-900/40 backdrop-blur-md border-b border-white/5 relative z-20">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<Show when={!selectedTaskId()} fallback={
|
||||||
|
<div class="flex items-center bg-indigo-500/10 border border-indigo-500/20 rounded-md px-2 py-1 shadow-[0_0_15px_rgba(99,102,241,0.1)] transition-all hover:bg-indigo-500/15">
|
||||||
|
<span class="text-[10px] font-black text-indigo-400 mr-2 tracking-tighter uppercase">MULTIX</span>
|
||||||
|
<div class="bg-indigo-500 rounded-sm w-3.5 h-3.5 flex items-center justify-center p-[1px]">
|
||||||
|
<div class="flex flex-col space-y-[1px] w-full items-center">
|
||||||
|
<div class="flex space-x-[1px]">
|
||||||
|
<div class="w-0.5 h-0.5 bg-black rounded-full" />
|
||||||
|
<div class="w-0.5 h-0.5 bg-black rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-[0.5px] bg-black rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTaskId(null)}
|
||||||
|
class="flex items-center space-x-2 text-zinc-400 hover:text-white transition-all duration-200 group active:scale-95"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} class="rotate-180 group-hover:-translate-x-0.5 transition-transform" />
|
||||||
|
<span class="text-xs font-semibold tracking-tight">Pipeline</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={selectedTaskId()}>
|
||||||
|
<div class="flex items-center space-x-1.5 px-2 py-1 bg-zinc-800/50 rounded-lg border border-white/5">
|
||||||
|
<ListTodo size={14} class="text-indigo-400" />
|
||||||
|
<span class="text-[10px] font-bold text-zinc-400">{tasks().length}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button class="p-1.5 text-zinc-500 hover:text-zinc-200 transition-colors hover:bg-zinc-800/50 rounded-md active:scale-90">
|
||||||
|
<Command size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTaskId(null)}
|
||||||
|
class={`p-1.5 rounded-md transition-all duration-200 group ${
|
||||||
|
selectedTaskId()
|
||||||
|
? "bg-indigo-500/10 border-indigo-500/20 text-white"
|
||||||
|
: "text-zinc-500 hover:text-white hover:bg-zinc-800/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<PanelRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 relative overflow-hidden flex flex-col">
|
||||||
|
<Show when={!selectedTaskId()}>
|
||||||
|
{/* TASK LIST VIEW - CODEX 5.1 Styled */}
|
||||||
|
<div class="flex-1 flex flex-col bg-zinc-900/20 animate-in fade-in slide-in-from-left-4 duration-300">
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-xl font-bold text-zinc-100 tracking-tight">Project Pipeline</h2>
|
||||||
|
<p class="text-xs text-zinc-500">Manage and orchestrate agentic tasks</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAddTask}
|
||||||
|
class="px-3 py-1.5 bg-indigo-500 text-white rounded-xl flex items-center justify-center hover:bg-indigo-600 active:scale-[0.97] transition-all shadow-lg shadow-indigo-500/20 font-bold text-xs"
|
||||||
|
>
|
||||||
|
<Plus size={14} class="mr-2" strokeWidth={3} />
|
||||||
|
New Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task List */}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<For each={tasks()}>
|
||||||
|
{(task) => (
|
||||||
|
<div
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||||
|
task.status === "completed"
|
||||||
|
? "bg-emerald-500/10"
|
||||||
|
: task.status === "in-progress"
|
||||||
|
? "bg-amber-500/10"
|
||||||
|
: "bg-zinc-700/50"
|
||||||
|
}`}>
|
||||||
|
{task.status === "completed" ? (
|
||||||
|
<CheckCircle2 size={16} class="text-emerald-500" />
|
||||||
|
) : task.status === "in-progress" ? (
|
||||||
|
<Loader2 size={16} class="text-amber-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle size={16} class="text-zinc-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-white font-semibold text-sm">{task.title}</h3>
|
||||||
|
<p class="text-zinc-400 text-xs">{formatTimestamp(task.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} class="text-zinc-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={selectedTaskId()}>
|
||||||
|
{/* TASK CHAT VIEW - When task is selected */}
|
||||||
|
<div class="flex-1 flex flex-col relative animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 pb-32">
|
||||||
|
<MessageBlockList
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={messageStore}
|
||||||
|
messageIds={messageIds}
|
||||||
|
lastAssistantIndex={lastAssistantIndex}
|
||||||
|
scrollContainer={() => scrollContainer}
|
||||||
|
setBottomSentinel={setBottomSentinel}
|
||||||
|
showThinking={() => true}
|
||||||
|
thinkingDefaultExpanded={() => true}
|
||||||
|
showUsageMetrics={() => true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CODEX 5.1 Chat Input Area */}
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-[#0a0a0b] via-[#0a0a0b]/95 to-transparent backdrop-blur-md">
|
||||||
|
<div class="bg-zinc-900/80 border border-white/10 rounded-2xl shadow-2xl p-4 space-y-4 transition-all focus-within:border-indigo-500/40 focus-within:ring-4 focus-within:ring-indigo-500/5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-2.5">
|
||||||
|
<div class="w-5 h-5 rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
||||||
|
<AtSign size={10} class="text-white" />
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] font-bold text-zinc-400 tracking-tight">TASK ASSISTANT</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="px-1.5 py-0.5 bg-zinc-800 text-[9px] font-black text-zinc-500 uppercase tracking-tighter rounded border border-white/5">
|
||||||
|
{selectedTask()?.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="relative">
|
||||||
|
<textarea
|
||||||
|
value={chatInput()}
|
||||||
|
onInput={(e) => setChatInput(e.currentTarget.value)}
|
||||||
|
placeholder="Message assistant about this task..."
|
||||||
|
class="w-full bg-transparent border-none focus:ring-0 text-sm text-zinc-200 placeholder-zinc-600 resize-none min-h-[44px] max-h-32 custom-scrollbar leading-relaxed disabled:opacity-50"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isSending()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-3 border-t border-white/5">
|
||||||
|
<div class="flex items-center space-x-4 text-zinc-500">
|
||||||
|
<button class="hover:text-indigo-400 transition-colors active:scale-90"><Hash size={16} /></button>
|
||||||
|
<button class="hover:text-indigo-400 transition-colors active:scale-90"><Mic size={16} /></button>
|
||||||
|
<div class="w-px h-4 bg-zinc-800" />
|
||||||
|
<span class="text-[10px] font-bold text-zinc-600 tracking-widest">CMD + ENTER</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!chatInput().trim() || isSending()}
|
||||||
|
class="px-4 py-1.5 bg-zinc-100 text-zinc-950 rounded-xl flex items-center justify-center hover:bg-white active:scale-[0.97] transition-all shadow-lg shadow-white/5 font-bold text-xs disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-100"
|
||||||
|
>
|
||||||
|
{isSending() ? (
|
||||||
|
<>
|
||||||
|
<div class="w-3 h-3 border-2 border-zinc-950 border-t-transparent rounded-full animate-spin mr-2" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Execute
|
||||||
|
<ArrowUp size={14} class="ml-2" strokeWidth={3} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
503
packages/ui/src/components/chat/multi-task-chat.tsx
Normal file
503
packages/ui/src/components/chat/multi-task-chat.tsx
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
import { createSignal, Show, onMount, For, createMemo, createEffect } from "solid-js";
|
||||||
|
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
|
||||||
|
import { instances } from "@/stores/instances";
|
||||||
|
import { sendMessage } from "@/stores/session-actions";
|
||||||
|
import { addTask, setActiveTask } from "@/stores/task-actions";
|
||||||
|
import { messageStoreBus } from "@/stores/message-v2/bus";
|
||||||
|
import MessageBlockList from "@/components/message-block-list";
|
||||||
|
import { formatTokenTotal } from "@/lib/formatters";
|
||||||
|
import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval } from "@/stores/solo-store";
|
||||||
|
import { getLogger } from "@/lib/logger";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
Plus,
|
||||||
|
CheckCircle2,
|
||||||
|
PanelRight,
|
||||||
|
ListTodo,
|
||||||
|
AtSign,
|
||||||
|
Hash,
|
||||||
|
Mic,
|
||||||
|
ArrowUp,
|
||||||
|
Terminal,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
X,
|
||||||
|
Zap,
|
||||||
|
Layers,
|
||||||
|
Shield,
|
||||||
|
Activity,
|
||||||
|
} from "lucide-solid";
|
||||||
|
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
|
||||||
|
import type { Task } from "@/types/session";
|
||||||
|
|
||||||
|
const log = getLogger("multix-chat");
|
||||||
|
|
||||||
|
interface MultiTaskChatProps {
|
||||||
|
instanceId: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||||
|
const selectedTaskId = () => session()?.activeTaskId || null;
|
||||||
|
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
|
||||||
|
const [isSending, setIsSending] = createSignal(false);
|
||||||
|
const [chatInput, setChatInput] = createSignal("");
|
||||||
|
let scrollContainer: HTMLDivElement | undefined;
|
||||||
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Scroll to bottom helper
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current session and tasks
|
||||||
|
const session = () => {
|
||||||
|
const instanceSessions = sessions().get(props.instanceId);
|
||||||
|
return instanceSessions?.get(props.sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasks = () => session()?.tasks || [];
|
||||||
|
const selectedTask = () => tasks().find(t => t.id === selectedTaskId());
|
||||||
|
|
||||||
|
// Message store integration
|
||||||
|
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
|
||||||
|
const lastAssistantIndex = () => {
|
||||||
|
const ids = filteredMessageIds();
|
||||||
|
const store = messageStore();
|
||||||
|
for (let i = ids.length - 1; i >= 0; i--) {
|
||||||
|
const msg = store.getMessage(ids[i]);
|
||||||
|
if (msg?.role === "assistant") return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter messages based on selected task - use store's session messages for the task session
|
||||||
|
const filteredMessageIds = () => {
|
||||||
|
const task = selectedTask();
|
||||||
|
if (!task) return []; // Show no messages in Pipeline view
|
||||||
|
|
||||||
|
// If task has a dedicated session, get messages from the store for that session
|
||||||
|
if (task.taskSessionId) {
|
||||||
|
const store = messageStore();
|
||||||
|
return store.getSessionMessageIds(task.taskSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to task.messageIds for backward compatibility
|
||||||
|
return task.messageIds || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: Auto-scroll is handled in two places:
|
||||||
|
// 1. After sending a message (in handleSendMessage)
|
||||||
|
// 2. During streaming (in the isAgentThinking effect below)
|
||||||
|
// We intentionally don't scroll on message count change to let users scroll freely
|
||||||
|
|
||||||
|
// Token and status tracking
|
||||||
|
const sessionUsage = createMemo(() => {
|
||||||
|
const store = messageStore();
|
||||||
|
return store.getSessionUsage(props.sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenStats = createMemo(() => {
|
||||||
|
const usage = sessionUsage();
|
||||||
|
return {
|
||||||
|
used: usage?.actualUsageTokens ?? 0,
|
||||||
|
total: usage?.totalCost ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTaskSessionId = createMemo(() => {
|
||||||
|
const task = selectedTask();
|
||||||
|
return task?.taskSessionId || props.sessionId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const solo = () => getSoloState(props.instanceId);
|
||||||
|
|
||||||
|
const isAgentThinking = createMemo(() => {
|
||||||
|
// Show thinking while we're actively sending
|
||||||
|
if (isSending()) return true;
|
||||||
|
|
||||||
|
const store = messageStore();
|
||||||
|
|
||||||
|
// Check for streaming in the specific task session
|
||||||
|
const taskSessionId = activeTaskSessionId();
|
||||||
|
const sessionRecord = store.state.sessions[taskSessionId];
|
||||||
|
const sessionMessages = sessionRecord ? sessionRecord.messageIds : [];
|
||||||
|
const isAnyStreaming = sessionMessages.some((id: string) => {
|
||||||
|
const m = store.getMessage(id);
|
||||||
|
return m?.role === "assistant" && (m.status === "streaming" || m.status === "sending");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAnyStreaming) return true;
|
||||||
|
|
||||||
|
// Also check the filtered message IDs (for tasks)
|
||||||
|
const ids = filteredMessageIds();
|
||||||
|
if (ids.length === 0) return false;
|
||||||
|
const lastMsg = store.getMessage(ids[ids.length - 1]);
|
||||||
|
return lastMsg?.role === "assistant" && (lastMsg.status === "streaming" || lastMsg.status === "sending");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll during streaming - must be after isAgentThinking is defined
|
||||||
|
createEffect(() => {
|
||||||
|
const streaming = isAgentThinking();
|
||||||
|
if (!streaming) return;
|
||||||
|
|
||||||
|
// During streaming, scroll periodically to keep up with content
|
||||||
|
const interval = setInterval(scrollToBottom, 300);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
const message = chatInput().trim();
|
||||||
|
if (!message || isSending()) return;
|
||||||
|
|
||||||
|
setIsSending(true);
|
||||||
|
log.info("handleSendMessage started", {
|
||||||
|
instanceId: props.instanceId,
|
||||||
|
sessionId: props.sessionId,
|
||||||
|
selectedTaskId: selectedTaskId(),
|
||||||
|
messageLength: message.length
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
let taskId = selectedTaskId();
|
||||||
|
let targetSessionId = props.sessionId;
|
||||||
|
|
||||||
|
// If no task selected, create one automatically
|
||||||
|
if (!taskId) {
|
||||||
|
log.info("No task selected, creating new task");
|
||||||
|
const title = message.length > 30 ? message.substring(0, 27) + "..." : message;
|
||||||
|
const result = await addTask(props.instanceId, props.sessionId, title);
|
||||||
|
taskId = result.id;
|
||||||
|
targetSessionId = result.taskSessionId || props.sessionId;
|
||||||
|
|
||||||
|
log.info("New task created", { taskId, targetSessionId });
|
||||||
|
setSelectedTaskId(taskId);
|
||||||
|
|
||||||
|
// If autonomous mode is on, we might want to queue it or set it as active
|
||||||
|
const s = solo();
|
||||||
|
if (s.isAutonomous) {
|
||||||
|
log.info("Autonomous mode active, setting active task or queuing");
|
||||||
|
if (!s.activeTaskId) {
|
||||||
|
setActiveTaskId(props.instanceId, taskId);
|
||||||
|
} else {
|
||||||
|
addToTaskQueue(props.instanceId, taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const task = selectedTask();
|
||||||
|
targetSessionId = task?.taskSessionId || props.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Target session identified", { targetSessionId, taskId });
|
||||||
|
|
||||||
|
const store = messageStore();
|
||||||
|
log.info("Message store check before sending", {
|
||||||
|
instanceId: props.instanceId,
|
||||||
|
storeExists: !!store,
|
||||||
|
messageCount: store?.getSessionMessageIds(targetSessionId).length
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendMessage(
|
||||||
|
props.instanceId,
|
||||||
|
targetSessionId,
|
||||||
|
message,
|
||||||
|
[],
|
||||||
|
taskId || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("sendMessage call completed");
|
||||||
|
setChatInput("");
|
||||||
|
|
||||||
|
// Auto-scroll to bottom after sending
|
||||||
|
setTimeout(scrollToBottom, 100);
|
||||||
|
} catch (error) {
|
||||||
|
log.error("handleSendMessage failed", error);
|
||||||
|
console.error("[MultiTaskChat] Send failed:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
log.info("handleSendMessage finished");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Enter to submit, Shift+Enter for new line
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main class="h-full flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30">
|
||||||
|
{/* Header */}
|
||||||
|
<header class="h-14 px-4 flex items-center justify-between bg-zinc-900/60 backdrop-blur-xl border-b border-white/5 relative z-30 shrink-0">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="flex items-center bg-indigo-500/10 border border-indigo-500/20 rounded-lg px-2.5 py-1.5 shadow-[0_0_20px_rgba(99,102,241,0.1)]">
|
||||||
|
<span class="text-[10px] font-black text-indigo-400 mr-2.5 tracking-tighter uppercase">MULTIX</span>
|
||||||
|
<div class="bg-indigo-500 rounded-md w-4 h-4 flex items-center justify-center shadow-lg shadow-indigo-500/40">
|
||||||
|
<Zap size={10} class="text-white fill-current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={selectedTaskId()}>
|
||||||
|
<div class="flex items-center space-x-2 animate-in fade-in slide-in-from-left-2 duration-300">
|
||||||
|
<ChevronRight size={14} class="text-zinc-600" />
|
||||||
|
<div class="flex items-center space-x-2 px-2.5 py-1 bg-white/5 rounded-lg border border-white/5">
|
||||||
|
<ListTodo size={14} class="text-indigo-400" />
|
||||||
|
<span class="text-[11px] font-bold text-zinc-100 truncate max-w-[120px]">
|
||||||
|
{selectedTask()?.title || "Active Task"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTaskId(null)}
|
||||||
|
class="ml-1 p-0.5 hover:bg-white/10 rounded-md transition-colors text-zinc-500 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button class="p-2 text-zinc-500 hover:text-white transition-all hover:bg-white/5 rounded-xl active:scale-90">
|
||||||
|
<Command size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button class="p-2 text-zinc-500 hover:text-white transition-all hover:bg-white/5 rounded-xl active:scale-90">
|
||||||
|
<PanelRight size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Task Tabs (Horizontal Scroll) */}
|
||||||
|
<Show when={tasks().length > 0}>
|
||||||
|
<div class="flex items-center bg-[#0a0a0b] border-b border-white/5 px-2 py-2 space-x-1.5 overflow-x-auto custom-scrollbar-hidden no-scrollbar shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTaskId(null)}
|
||||||
|
class={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shrink-0 border ${!selectedTaskId()
|
||||||
|
? "bg-indigo-500/15 text-indigo-400 border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.1)]"
|
||||||
|
: "text-zinc-500 hover:text-zinc-300 hover:bg-white/5 border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers size={12} class={!selectedTaskId() ? "text-indigo-400" : "text-zinc-600"} />
|
||||||
|
<span>Pipeline</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="w-px h-4 bg-white/10 shrink-0 mx-0.5" />
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-1.5 overflow-x-auto no-scrollbar">
|
||||||
|
<For each={tasks()}>
|
||||||
|
{(task) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTaskId(task.id)}
|
||||||
|
class={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-[10px] font-bold transition-all shrink-0 max-w-[160px] border group ${selectedTaskId() === task.id
|
||||||
|
? "bg-white/10 text-zinc-100 border-white/20 shadow-xl shadow-black/20"
|
||||||
|
: "text-zinc-500 hover:text-zinc-300 hover:bg-white/5 border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div class={`w-2 h-2 rounded-full transition-all duration-500 ${task.status === "completed" ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]" :
|
||||||
|
task.status === "interrupted" ? "bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]" :
|
||||||
|
"bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.4)] animate-pulse"
|
||||||
|
}`} />
|
||||||
|
<span class="truncate">{task.title}</span>
|
||||||
|
<Show when={selectedTaskId() === task.id}>
|
||||||
|
<div class="ml-1 w-1 h-1 bg-indigo-400 rounded-full animate-ping" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setChatInput("");
|
||||||
|
setSelectedTaskId(null);
|
||||||
|
}}
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded-xl text-zinc-500 hover:text-indigo-400 hover:bg-indigo-500/10 transition-all shrink-0 ml-1 border border-transparent hover:border-indigo-500/20"
|
||||||
|
title="New Task"
|
||||||
|
>
|
||||||
|
<Plus size={16} strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Main Content Area - min-h-0 is critical for flex containers with overflow */}
|
||||||
|
<div class="flex-1 min-h-0 relative overflow-hidden flex flex-col">
|
||||||
|
<div
|
||||||
|
ref={scrollContainer}
|
||||||
|
class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar"
|
||||||
|
>
|
||||||
|
<Show when={!selectedTaskId()} fallback={
|
||||||
|
<div class="p-3 pb-4 overflow-x-hidden">
|
||||||
|
<MessageBlockList
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={activeTaskSessionId()}
|
||||||
|
store={messageStore}
|
||||||
|
messageIds={filteredMessageIds}
|
||||||
|
lastAssistantIndex={lastAssistantIndex}
|
||||||
|
showThinking={() => true}
|
||||||
|
thinkingDefaultExpanded={() => true}
|
||||||
|
showUsageMetrics={() => true}
|
||||||
|
scrollContainer={() => scrollContainer}
|
||||||
|
setBottomSentinel={setBottomSentinel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{/* Pipeline View */}
|
||||||
|
<div class="p-4 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h2 class="text-2xl font-black text-white tracking-tight leading-none">Pipeline</h2>
|
||||||
|
<p class="text-xs font-medium text-zinc-500 uppercase tracking-[0.2em]">Agentic Orchestration</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">Active Threads</span>
|
||||||
|
<div class="h-px flex-1 bg-white/5 mx-4" />
|
||||||
|
<span class="text-[10px] font-black text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded border border-indigo-500/20">
|
||||||
|
{tasks().length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<For each={tasks()} fallback={
|
||||||
|
<div class="group relative p-8 rounded-3xl border border-dashed border-white/5 bg-zinc-900/20 flex flex-col items-center justify-center text-center space-y-4 transition-all hover:bg-zinc-900/40 hover:border-white/10">
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-white/5 flex items-center justify-center text-zinc-600 group-hover:text-indigo-400 group-hover:scale-110 transition-all duration-500">
|
||||||
|
<Plus size={24} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-bold text-zinc-400">No active tasks</p>
|
||||||
|
<p class="text-[11px] text-zinc-600">Send a message below to start a new thread</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{(task) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTaskId(task.id)}
|
||||||
|
class="group relative p-4 rounded-2xl border border-white/5 bg-zinc-900/40 hover:bg-zinc-800/60 hover:border-indigo-500/30 transition-all duration-300 text-left flex items-start space-x-4 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<div class={`mt-1 w-2 h-2 rounded-full shadow-[0_0_10px_rgba(var(--color),0.5)] ${task.status === "completed" ? "bg-emerald-500 shadow-emerald-500/40" :
|
||||||
|
task.status === "in-progress" ? "bg-indigo-500 shadow-indigo-500/40 animate-pulse" :
|
||||||
|
"bg-zinc-600 shadow-zinc-600/20"
|
||||||
|
}`} />
|
||||||
|
<div class="flex-1 min-w-0 space-y-1">
|
||||||
|
<p class="text-sm font-bold text-zinc-100 truncate group-hover:text-white transition-colors">
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center space-x-3 text-[10px] font-bold text-zinc-500 uppercase tracking-tight">
|
||||||
|
<span>{new Date(task.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
<span class="w-1 h-1 rounded-full bg-zinc-800" />
|
||||||
|
<span>{task.messageIds?.length || 0} messages</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Input Area - Fixed at bottom */}
|
||||||
|
<div class="p-3 bg-[#0a0a0b] border-t border-white/5 shrink-0">
|
||||||
|
<div class="w-full bg-zinc-900/80 border border-white/10 rounded-2xl shadow-lg p-3">
|
||||||
|
{/* Header Row */}
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-5 h-5 rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center">
|
||||||
|
<AtSign size={10} class="text-white" strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-[10px] font-bold text-zinc-100 uppercase tracking-wide">
|
||||||
|
{selectedTaskId() ? "Task Context" : "Global Pipeline"}
|
||||||
|
</span>
|
||||||
|
<span class="text-[9px] text-zinc-500 uppercase">
|
||||||
|
{selectedTaskId() ? "MultiX Threaded" : "Auto-Task"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAutonomous(props.instanceId)}
|
||||||
|
class={`px-2 py-0.5 rounded text-[9px] font-bold uppercase border ${solo().isAutonomous
|
||||||
|
? "bg-indigo-500/20 border-indigo-500/40 text-indigo-400"
|
||||||
|
: "bg-white/5 border-white/10 text-zinc-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAutoApproval(props.instanceId)}
|
||||||
|
class={`px-2 py-0.5 rounded text-[9px] font-bold uppercase border ${solo().autoApproval
|
||||||
|
? "bg-emerald-500/20 border-emerald-500/40 text-emerald-400"
|
||||||
|
: "bg-white/5 border-white/10 text-zinc-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Shield
|
||||||
|
</button>
|
||||||
|
<Show when={tokenStats().used > 0}>
|
||||||
|
<div class="px-2 py-0.5 bg-emerald-500/10 rounded border border-emerald-500/20 text-[9px] font-bold text-emerald-400">
|
||||||
|
{formatTokenTotal(tokenStats().used)}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={isSending() || isAgentThinking()}>
|
||||||
|
<div class="flex items-center space-x-1 px-2 py-0.5 bg-indigo-500/10 rounded border border-indigo-500/20">
|
||||||
|
<Loader2 size={10} class="text-indigo-400 animate-spin" />
|
||||||
|
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "Thinking" : "Sending"}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Input */}
|
||||||
|
<textarea
|
||||||
|
value={chatInput()}
|
||||||
|
onInput={(e) => setChatInput(e.currentTarget.value)}
|
||||||
|
placeholder={selectedTaskId() ? "Send instruction to this task..." : "Type to create a new task and begin..."}
|
||||||
|
class="w-full bg-transparent border-none focus:ring-0 focus:outline-none text-[13px] text-zinc-100 placeholder-zinc-600 resize-none min-h-[40px] max-h-32 leading-relaxed disabled:opacity-50"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isSending()}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Footer Row */}
|
||||||
|
<div class="flex items-center justify-between pt-2 border-t border-white/5 mt-2">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
|
||||||
|
<Hash size={14} />
|
||||||
|
</button>
|
||||||
|
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
|
||||||
|
<Mic size={14} />
|
||||||
|
</button>
|
||||||
|
<div class="w-px h-3 bg-zinc-800" />
|
||||||
|
<div class="flex items-center space-x-1 text-zinc-600">
|
||||||
|
<kbd class="px-1.5 py-0.5 bg-zinc-800 rounded text-[9px] font-bold border border-white/5">ENTER</kbd>
|
||||||
|
<span class="text-[9px]">to send</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!chatInput().trim() || isSending()}
|
||||||
|
class="px-4 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-[11px] font-bold uppercase tracking-wide transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center space-x-1.5"
|
||||||
|
>
|
||||||
|
<Show when={isSending()} fallback={
|
||||||
|
<>
|
||||||
|
<span>{selectedTaskId() ? "Update Task" : "Launch Task"}</span>
|
||||||
|
<ArrowUp size={12} strokeWidth={3} />
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<Loader2 size={12} class="animate-spin" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
packages/ui/src/components/instance/editor.tsx
Normal file
52
packages/ui/src/components/instance/editor.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Component, For, Show } from "solid-js"
|
||||||
|
import { FileNode } from "./sidebar"
|
||||||
|
|
||||||
|
interface EditorProps {
|
||||||
|
file: FileNode | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Editor: Component<EditorProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={props.file}
|
||||||
|
fallback={
|
||||||
|
<div class="flex-1 flex items-center justify-center text-zinc-500 bg-[#0d0d0d]">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-4 opacity-20 flex justify-center">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||||
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p>Select a file to start editing</p>
|
||||||
|
<p class="text-sm mt-2 opacity-60">Press Ctrl+P to search</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex-1 overflow-hidden flex flex-col bg-[#0d0d0d]">
|
||||||
|
<div class="h-10 glass border-b border-white/5 flex items-center px-4 space-x-2 shrink-0">
|
||||||
|
<span class="text-xs text-zinc-400 font-medium">{props.file?.name}</span>
|
||||||
|
<span class="text-[10px] text-zinc-600 uppercase">{props.file?.language || "text"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 p-6 overflow-auto mono text-sm leading-relaxed">
|
||||||
|
<pre class="text-zinc-300">
|
||||||
|
<Show
|
||||||
|
when={props.file?.content}
|
||||||
|
fallback={<span class="italic text-zinc-600">// Empty file</span>}
|
||||||
|
>
|
||||||
|
<For each={props.file?.content?.split("\n")}>
|
||||||
|
{(line, i) => (
|
||||||
|
<div class="flex group">
|
||||||
|
<span class="w-12 text-zinc-600 select-none text-right pr-4">{i() + 1}</span>
|
||||||
|
<span class="whitespace-pre">{line}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
getSessionFamily,
|
getSessionFamily,
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
|
executeCustomCommand,
|
||||||
|
runShellCommand,
|
||||||
} from "../../stores/sessions"
|
} from "../../stores/sessions"
|
||||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
@@ -48,14 +50,25 @@ import InfoView from "../info-view"
|
|||||||
import InstanceServiceStatus from "../instance-service-status"
|
import InstanceServiceStatus from "../instance-service-status"
|
||||||
import AgentSelector from "../agent-selector"
|
import AgentSelector from "../agent-selector"
|
||||||
import ModelSelector from "../model-selector"
|
import ModelSelector from "../model-selector"
|
||||||
|
import ModelStatusSelector from "../model-status-selector"
|
||||||
import CommandPalette from "../command-palette"
|
import CommandPalette from "../command-palette"
|
||||||
import Kbd from "../kbd"
|
import Kbd from "../kbd"
|
||||||
|
import MultiTaskChat from "../chat/multi-task-chat"
|
||||||
import { TodoListView } from "../tool-call/renderers/todo"
|
import { TodoListView } from "../tool-call/renderers/todo"
|
||||||
import ContextUsagePanel from "../session/context-usage-panel"
|
import ContextUsagePanel from "../session/context-usage-panel"
|
||||||
import SessionView from "../session/session-view"
|
import SessionView from "../session/session-view"
|
||||||
|
import { Sidebar, type FileNode } from "./sidebar"
|
||||||
|
import { Editor } from "./editor"
|
||||||
|
import { serverApi } from "../../lib/api-client"
|
||||||
|
import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield } from "lucide-solid"
|
||||||
import { formatTokenTotal } from "../../lib/formatters"
|
import { formatTokenTotal } from "../../lib/formatters"
|
||||||
import { sseManager } from "../../lib/sse-manager"
|
import { sseManager } from "../../lib/sse-manager"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
|
import {
|
||||||
|
getSoloState,
|
||||||
|
toggleAutonomous,
|
||||||
|
toggleAutoApproval,
|
||||||
|
} from "../../stores/solo-store"
|
||||||
import {
|
import {
|
||||||
SESSION_SIDEBAR_EVENT,
|
SESSION_SIDEBAR_EVENT,
|
||||||
type SessionSidebarRequestAction,
|
type SessionSidebarRequestAction,
|
||||||
@@ -128,7 +141,26 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
|
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
|
||||||
const [resizeStartX, setResizeStartX] = createSignal(0)
|
const [resizeStartX, setResizeStartX] = createSignal(0)
|
||||||
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
|
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
|
||||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp"])
|
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp", "plan"])
|
||||||
|
const [currentFile, setCurrentFile] = createSignal<FileNode | null>(null)
|
||||||
|
const [isSoloOpen, setIsSoloOpen] = createSignal(true)
|
||||||
|
|
||||||
|
// Handler to load file content when selected
|
||||||
|
const handleFileSelect = async (file: FileNode) => {
|
||||||
|
try {
|
||||||
|
const response = await serverApi.readWorkspaceFile(props.instance.id, file.path)
|
||||||
|
const language = file.name.split('.').pop() || 'text'
|
||||||
|
setCurrentFile({
|
||||||
|
...file,
|
||||||
|
content: response.contents,
|
||||||
|
language,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Failed to read file content', error)
|
||||||
|
// Still show the file but without content
|
||||||
|
setCurrentFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
||||||
|
|
||||||
@@ -326,6 +358,58 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
showCommandPalette(props.instance.id)
|
showCommandPalette(props.instance.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [isFixing, setIsFixing] = createSignal(false)
|
||||||
|
const [isBuilding, setIsBuilding] = createSignal(false)
|
||||||
|
|
||||||
|
const handleSmartFix = async () => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId || sessionId === "info" || isFixing()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFixing(true)
|
||||||
|
try {
|
||||||
|
// Smart Fix targets the active task if available, otherwise general fix
|
||||||
|
const session = activeSessionForInstance()
|
||||||
|
const activeTaskId = session?.activeTaskId
|
||||||
|
const args = activeTaskId ? `task:${activeTaskId}` : ""
|
||||||
|
|
||||||
|
await executeCustomCommand(props.instance.id, sessionId, "fix", args)
|
||||||
|
|
||||||
|
// Auto-open right panel to show agent progress if it's not open
|
||||||
|
if (!rightOpen()) {
|
||||||
|
setRightOpen(true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to run Smart Fix command", error)
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => setIsFixing(false), 2000) // Reset after delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBuild = async () => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId || sessionId === "info" || isBuilding()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBuilding(true)
|
||||||
|
try {
|
||||||
|
await runShellCommand(props.instance.id, sessionId, "build")
|
||||||
|
|
||||||
|
// Auto-open right panel to show build logs if it's not open
|
||||||
|
if (!rightOpen()) {
|
||||||
|
setRightOpen(true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to run Build command", error)
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => setIsBuilding(false), 2000) // Reset after delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
||||||
|
|
||||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
||||||
@@ -648,7 +732,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const pinLeftDrawer = () => {
|
const pinLeftDrawer = () => {
|
||||||
blurIfInside(leftDrawerContentEl())
|
blurIfInside(leftDrawerContentEl())
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setLeftPinned(true)
|
setLeftPinned(true)
|
||||||
@@ -707,11 +791,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
if (state === "pinned") return
|
if (state === "pinned") return
|
||||||
if (state === "floating-closed") {
|
if (state === "floating-closed") {
|
||||||
setRightOpen(true)
|
setRightOpen(true)
|
||||||
|
setIsSoloOpen(false)
|
||||||
measureDrawerHost()
|
measureDrawerHost()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
blurIfInside(rightDrawerContentEl())
|
blurIfInside(rightDrawerContentEl())
|
||||||
setRightOpen(false)
|
setRightOpen(false)
|
||||||
|
setIsSoloOpen(false)
|
||||||
focusTarget(rightToggleButtonEl())
|
focusTarget(rightToggleButtonEl())
|
||||||
measureDrawerHost()
|
measureDrawerHost()
|
||||||
}
|
}
|
||||||
@@ -757,90 +843,27 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LeftDrawerContent = () => (
|
const LeftDrawerContent = () => (
|
||||||
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
|
<Sidebar
|
||||||
<div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base">
|
instanceId={props.instance.id}
|
||||||
<div class="flex flex-col gap-1">
|
isOpen={leftOpen()}
|
||||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
onFileSelect={handleFileSelect}
|
||||||
<div class="session-sidebar-shortcuts">
|
sessions={Array.from(activeSessions().values())}
|
||||||
<Show when={keyboardShortcuts().length}>
|
activeSessionId={activeSessionIdForInstance() || undefined}
|
||||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
onSessionSelect={handleSessionSelect}
|
||||||
</Show>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Show when={!isPhoneLayout()}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="inherit"
|
|
||||||
aria-label={leftPinned() ? "Unpin left drawer" : "Pin left drawer"}
|
|
||||||
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
|
|
||||||
>
|
|
||||||
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
|
||||||
</IconButton>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
|
||||||
<SessionList
|
|
||||||
instanceId={props.instance.id}
|
|
||||||
sessions={activeSessions()}
|
|
||||||
activeSessionId={activeSessionIdForInstance()}
|
|
||||||
onSelect={handleSessionSelect}
|
|
||||||
onClose={(id) => {
|
|
||||||
const result = props.onCloseSession(id)
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.catch((error) => log.error("Failed to close session:", error))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onNew={() => {
|
|
||||||
const result = props.onNewSession()
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.catch((error) => log.error("Failed to create session:", error))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
showHeader={false}
|
|
||||||
showFooter={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
<Show when={activeSessionForInstance()}>
|
|
||||||
{(activeSession) => (
|
|
||||||
<>
|
|
||||||
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
|
|
||||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
|
||||||
<AgentSelector
|
|
||||||
instanceId={props.instance.id}
|
|
||||||
sessionId={activeSession().id}
|
|
||||||
currentAgent={activeSession().agent}
|
|
||||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="sidebar-selector-hints" aria-hidden="true">
|
|
||||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
|
||||||
<Kbd shortcut="cmd+shift+a" />
|
|
||||||
</span>
|
|
||||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
|
||||||
<Kbd shortcut="cmd+shift+m" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModelSelector
|
|
||||||
instanceId={props.instance.id}
|
|
||||||
sessionId={activeSession().id}
|
|
||||||
currentModel={activeSession().model}
|
|
||||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const RightDrawerContent = () => {
|
const RightDrawerContent = () => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
|
||||||
|
if (sessionId && sessionId !== "info") {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
||||||
|
<MultiTaskChat instanceId={props.instance.id} sessionId={sessionId} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderPlanSectionContent = () => {
|
const renderPlanSectionContent = () => {
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!sessionId || sessionId === "info") {
|
if (!sessionId || sessionId === "info") {
|
||||||
@@ -1011,6 +1034,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
|
if (isSoloOpen()) return null; // MultiX Mode uses the main stream area
|
||||||
|
|
||||||
if (rightPinned()) {
|
if (rightPinned()) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -1075,215 +1100,239 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
const sessionLayout = (
|
const sessionLayout = (
|
||||||
<div
|
<div
|
||||||
class="session-shell-panels flex flex-col flex-1 min-h-0 overflow-x-hidden"
|
class="session-shell-panels flex flex-col flex-1 min-h-0 overflow-x-hidden relative bg-[#050505]"
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
setDrawerHost(element)
|
setDrawerHost(element)
|
||||||
measureDrawerHost()
|
measureDrawerHost()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
{/* Background Decorator - Antigravity Glows */}
|
||||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
<div class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-600/10 blur-[120px] rounded-full pointer-events-none z-0" />
|
||||||
<Show
|
<div class="absolute bottom-[-10%] right-[-10%] w-[30%] h-[30%] bg-purple-600/5 blur-[100px] rounded-full pointer-events-none z-0" />
|
||||||
when={!isPhoneLayout()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex flex-col w-full gap-1.5">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
|
||||||
<IconButton
|
|
||||||
ref={setLeftToggleButtonEl}
|
|
||||||
color="inherit"
|
|
||||||
onClick={handleLeftAppBarButtonClick}
|
|
||||||
aria-label={leftAppBarButtonLabel()}
|
|
||||||
size="small"
|
|
||||||
aria-expanded={leftDrawerState() !== "floating-closed"}
|
|
||||||
disabled={leftDrawerState() === "pinned"}
|
|
||||||
>
|
|
||||||
{leftAppBarButtonIcon()}
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-1 justify-center">
|
<AppBar position="sticky" color="default" elevation={0} class="border-b border-white/5 bg-[#050505]/80 backdrop-blur-md z-20">
|
||||||
<button
|
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center justify-between gap-2 py-0 min-h-[40px]">
|
||||||
type="button"
|
<div class="flex items-center space-x-4">
|
||||||
class="connection-status-button px-2 py-0.5 text-xs"
|
<IconButton
|
||||||
onClick={handleCommandPaletteClick}
|
ref={setLeftToggleButtonEl}
|
||||||
aria-label="Open command palette"
|
color="inherit"
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
onClick={handleLeftAppBarButtonClick}
|
||||||
>
|
aria-label={leftAppBarButtonLabel()}
|
||||||
Command Palette
|
size="small"
|
||||||
</button>
|
class="text-zinc-500 hover:text-zinc-200"
|
||||||
<span class="connection-status-shortcut-hint">
|
>
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<MenuIcon fontSize="small" />
|
||||||
</span>
|
</IconButton>
|
||||||
<span
|
<div class="flex items-center space-x-2">
|
||||||
class={`status-indicator ${connectionStatusClass()}`}
|
<div class="w-2.5 h-2.5 rounded-full bg-[#f87171] opacity-60" />
|
||||||
aria-label={`Connection ${connectionStatus()}`}
|
<div class="w-2.5 h-2.5 rounded-full bg-[#fbbf24] opacity-60" />
|
||||||
>
|
<div class="w-2.5 h-2.5 rounded-full bg-[#4ade80] opacity-60" />
|
||||||
<span class="status-dot" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
ref={setRightToggleButtonEl}
|
|
||||||
color="inherit"
|
|
||||||
onClick={handleRightAppBarButtonClick}
|
|
||||||
aria-label={rightAppBarButtonLabel()}
|
|
||||||
size="small"
|
|
||||||
aria-expanded={rightDrawerState() !== "floating-closed"}
|
|
||||||
disabled={rightDrawerState() === "pinned"}
|
|
||||||
>
|
|
||||||
{rightAppBarButtonIcon()}
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
|
|
||||||
<IconButton
|
|
||||||
ref={setLeftToggleButtonEl}
|
|
||||||
color="inherit"
|
|
||||||
onClick={handleLeftAppBarButtonClick}
|
|
||||||
aria-label={leftAppBarButtonLabel()}
|
|
||||||
size="small"
|
|
||||||
aria-expanded={leftDrawerState() !== "floating-closed"}
|
|
||||||
disabled={leftDrawerState() === "pinned"}
|
|
||||||
>
|
|
||||||
{leftAppBarButtonIcon()}
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<Show when={!showingInfoView()}>
|
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="connection-status-button px-2 py-0.5 text-xs"
|
|
||||||
onClick={handleCommandPaletteClick}
|
|
||||||
aria-label="Open command palette"
|
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
|
||||||
>
|
|
||||||
Command Palette
|
|
||||||
</button>
|
|
||||||
<span class="connection-status-shortcut-hint">
|
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="session-toolbar-right flex items-center gap-3">
|
|
||||||
<div class="connection-status-meta flex items-center gap-3">
|
|
||||||
<Show when={connectionStatus() === "connected"}>
|
|
||||||
<span class="status-indicator connected">
|
|
||||||
<span class="status-dot" />
|
|
||||||
<span class="status-text">Connected</span>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={connectionStatus() === "connecting"}>
|
|
||||||
<span class="status-indicator connecting">
|
|
||||||
<span class="status-dot" />
|
|
||||||
<span class="status-text">Connecting...</span>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
|
||||||
<span class="status-indicator disconnected">
|
|
||||||
<span class="status-dot" />
|
|
||||||
<span class="status-text">Disconnected</span>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
ref={setRightToggleButtonEl}
|
|
||||||
color="inherit"
|
|
||||||
onClick={handleRightAppBarButtonClick}
|
|
||||||
aria-label={rightAppBarButtonLabel()}
|
|
||||||
size="small"
|
|
||||||
aria-expanded={rightDrawerState() !== "floating-closed"}
|
|
||||||
disabled={rightDrawerState() === "pinned"}
|
|
||||||
>
|
|
||||||
{rightAppBarButtonIcon()}
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden md:flex items-center bg-white/5 border border-white/5 rounded-full px-3 py-1 space-x-2 text-zinc-400 group hover:border-white/10 transition-all cursor-pointer" onClick={handleCommandPaletteClick}>
|
||||||
|
<Search size={14} />
|
||||||
|
<span class="text-[11px] min-w-[200px]">Search your project...</span>
|
||||||
|
<div class="flex items-center space-x-1 opacity-40">
|
||||||
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<Show when={activeSessionIdForInstance() && activeSessionIdForInstance() !== "info"}>
|
||||||
|
<ModelStatusSelector
|
||||||
|
instanceId={props.instance.id}
|
||||||
|
sessionId={activeSessionIdForInstance()!}
|
||||||
|
currentModel={activeSessionForInstance()?.model || { providerId: "", modelId: "" }}
|
||||||
|
onModelChange={async (model) => {
|
||||||
|
const sid = activeSessionIdForInstance()
|
||||||
|
if (sid) await props.handleSidebarModelChange(sid, model)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* SmartX Mode Buttons (Integrated HUD) */}
|
||||||
|
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-2 py-1 space-x-1">
|
||||||
|
<button
|
||||||
|
onClick={handleSmartFix}
|
||||||
|
disabled={isFixing()}
|
||||||
|
title="Smart Fix"
|
||||||
|
class={`transition-all flex items-center space-x-1.5 px-2 py-1 rounded-full hover:bg-white/10 ${isFixing() ? "text-blue-500" : "text-zinc-400 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
<Show when={isFixing()} fallback={<Sparkles size={14} class="text-blue-400" />}>
|
||||||
|
<Loader2 size={14} class="animate-spin text-blue-400" />
|
||||||
|
</Show>
|
||||||
|
<span class="text-[10px] font-bold uppercase tracking-tight">
|
||||||
|
{isFixing() ? "FIXING..." : "SMART FIX"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="w-px h-3 bg-white/10" />
|
||||||
|
<button
|
||||||
|
onClick={handleBuild}
|
||||||
|
disabled={isBuilding()}
|
||||||
|
title="Build"
|
||||||
|
class={`transition-all flex items-center space-x-1.5 px-2 py-1 rounded-full hover:bg-white/10 ${isBuilding() ? "text-indigo-500" : "text-zinc-400 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
<Show when={isBuilding()} fallback={<TerminalIcon size={14} />}>
|
||||||
|
<Loader2 size={14} class="animate-spin text-indigo-400" />
|
||||||
|
</Show>
|
||||||
|
<span class="text-[10px] font-bold uppercase tracking-tight">
|
||||||
|
{isBuilding() ? "BUILDING..." : "BUILD"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SOLO Mode & Auto-Approval Toggles */}
|
||||||
|
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-1.5 py-1 space-x-1">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAutonomous(props.instance.id)}
|
||||||
|
title="Toggle Autonomous Mode (SOLO)"
|
||||||
|
class={`flex items-center space-x-1.5 px-2 py-0.5 rounded-full transition-all ${getSoloState(props.instance.id).isAutonomous
|
||||||
|
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
|
||||||
|
: "text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Zap size={12} class={getSoloState(props.instance.id).isAutonomous ? "animate-pulse" : ""} />
|
||||||
|
<span class="text-[9px] font-black uppercase tracking-tighter">SOLO</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAutoApproval(props.instance.id)}
|
||||||
|
title="Toggle Auto-Approval (SHIELD)"
|
||||||
|
class={`flex items-center space-x-1.5 px-2 py-0.5 rounded-full transition-all ${getSoloState(props.instance.id).autoApproval
|
||||||
|
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||||
|
: "text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield size={12} />
|
||||||
|
<span class="text-[9px] font-black uppercase tracking-tighter">Shield</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newState = !(rightOpen() && isSoloOpen())
|
||||||
|
setRightOpen(newState)
|
||||||
|
setIsSoloOpen(newState)
|
||||||
|
}}
|
||||||
|
class={`flex items-center space-x-1.5 px-3 py-1 rounded-full text-[11px] font-bold transition-all ${(rightOpen() && isSoloOpen()) ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'bg-white/5 text-zinc-400 border border-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span class={`w-1.5 h-1.5 bg-current rounded-full ${(rightOpen() && isSoloOpen()) ? 'animate-pulse' : ''}`} />
|
||||||
|
<span>MULTIX MODE</span>
|
||||||
|
</button>
|
||||||
|
<IconButton
|
||||||
|
ref={setRightToggleButtonEl}
|
||||||
|
color="inherit"
|
||||||
|
onClick={handleRightAppBarButtonClick}
|
||||||
|
aria-label={rightAppBarButtonLabel()}
|
||||||
|
size="small"
|
||||||
|
class="text-zinc-500 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
{rightAppBarButtonIcon()}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flex: 1, minHeight: 0, overflowX: "hidden" }}>
|
<Box sx={{ display: "flex", flex: 1, minHeight: 0, overflowX: "hidden", position: "relative", zIndex: 10 }}>
|
||||||
{renderLeftPanel()}
|
{renderLeftPanel()}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
sx={{ flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", overflowX: "hidden" }}
|
sx={{ flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", overflowX: "hidden" }}
|
||||||
class="content-area"
|
class="content-area relative"
|
||||||
>
|
>
|
||||||
<Show
|
<div class="flex-1 flex overflow-hidden">
|
||||||
when={showingInfoView()}
|
<Editor file={currentFile()} />
|
||||||
fallback={
|
|
||||||
<Show
|
<div class="flex-1 flex flex-col relative border-l border-white/5">
|
||||||
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
|
<Show when={isSoloOpen()}>
|
||||||
fallback={
|
<div class="flex-1 flex flex-col min-h-0">
|
||||||
<div class="flex items-center justify-center h-full">
|
<MultiTaskChat instanceId={props.instance.id} sessionId={activeSessionIdForInstance() || ""} />
|
||||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
</div>
|
||||||
<p class="mb-2">No session selected</p>
|
|
||||||
<p class="text-sm">Select a session to view messages</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={cachedSessionIds()}>
|
|
||||||
{(sessionId) => {
|
|
||||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
|
||||||
style={{ display: isActive() ? "flex" : "none" }}
|
|
||||||
data-session-id={sessionId}
|
|
||||||
aria-hidden={!isActive()}
|
|
||||||
>
|
|
||||||
<SessionView
|
|
||||||
sessionId={sessionId}
|
|
||||||
activeSessions={activeSessions()}
|
|
||||||
instanceId={props.instance.id}
|
|
||||||
instanceFolder={props.instance.folder}
|
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
|
||||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
|
||||||
onSidebarToggle={() => setLeftOpen(true)}
|
|
||||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
|
||||||
isActive={isActive()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
</Show>
|
||||||
}
|
|
||||||
>
|
<div class="flex-1 flex flex-col relative"
|
||||||
<div class="info-view-pane flex flex-col flex-1 min-h-0 overflow-y-auto">
|
style={{ display: isSoloOpen() ? "none" : "flex" }}>
|
||||||
<InfoView instanceId={props.instance.id} />
|
<Show
|
||||||
|
when={showingInfoView()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex items-center justify-center h-full">
|
||||||
|
<div class="text-center text-zinc-500">
|
||||||
|
<p class="mb-2">No session selected</p>
|
||||||
|
<p class="text-sm">Select a session to view messages</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={cachedSessionIds()}>
|
||||||
|
{(sessionId) => {
|
||||||
|
const isActive = () => activeSessionIdForInstance() === sessionId
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||||
|
style={{ display: isActive() ? "flex" : "none" }}
|
||||||
|
data-session-id={sessionId}
|
||||||
|
aria-hidden={!isActive()}
|
||||||
|
>
|
||||||
|
<SessionView
|
||||||
|
sessionId={sessionId}
|
||||||
|
activeSessions={activeSessions()}
|
||||||
|
instanceId={props.instance.id}
|
||||||
|
instanceFolder={props.instance.folder}
|
||||||
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
|
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||||
|
onSidebarToggle={() => setLeftOpen(true)}
|
||||||
|
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||||
|
isActive={isActive()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="info-view-pane flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<InfoView instanceId={props.instance.id} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Toolbar/Terminal Area */}
|
||||||
|
<footer class="h-8 glass border-t border-white/5 flex items-center justify-between px-3 text-[10px] text-zinc-500 tracking-wide z-10 shrink-0">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex items-center space-x-1.5 cursor-pointer hover:text-zinc-300">
|
||||||
|
<TerminalIcon size={12} />
|
||||||
|
<span>TERMINAL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4 uppercase font-bold">
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_5px_rgba(34,197,94,0.5)]" />
|
||||||
|
<span>Sync Active</span>
|
||||||
|
</div>
|
||||||
|
<Show when={activeSessionForInstance()}>
|
||||||
|
{(session) => (
|
||||||
|
<>
|
||||||
|
<span class="hover:text-zinc-300 cursor-pointer">{session().model.modelId}</span>
|
||||||
|
<span class="hover:text-zinc-300 cursor-pointer">{session().agent}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{renderRightPanel()}
|
{renderRightPanel()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Floating Action Buttons removed - Integrated into Header */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
203
packages/ui/src/components/instance/sidebar.tsx
Normal file
203
packages/ui/src/components/instance/sidebar.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { Component, createSignal, For, Show, createEffect } from "solid-js"
|
||||||
|
import {
|
||||||
|
Files,
|
||||||
|
Search,
|
||||||
|
GitBranch,
|
||||||
|
Play,
|
||||||
|
Settings,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Folder,
|
||||||
|
User,
|
||||||
|
FileCode,
|
||||||
|
FileJson,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
} from "lucide-solid"
|
||||||
|
import { serverApi } from "../../lib/api-client"
|
||||||
|
|
||||||
|
export interface FileNode {
|
||||||
|
name: string
|
||||||
|
type: "file" | "directory"
|
||||||
|
path: string
|
||||||
|
language?: string
|
||||||
|
content?: string
|
||||||
|
children?: FileNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
instanceId: string
|
||||||
|
onFileSelect: (file: FileNode) => void
|
||||||
|
isOpen: boolean
|
||||||
|
sessions: any[] // Existing sessions to display in one of the tabs
|
||||||
|
activeSessionId?: string
|
||||||
|
onSessionSelect: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileIcon = (fileName: string) => {
|
||||||
|
if (fileName.endsWith(".tsx") || fileName.endsWith(".ts"))
|
||||||
|
return <FileCode size={16} class="text-blue-400" />
|
||||||
|
if (fileName.endsWith(".json")) return <FileJson size={16} class="text-yellow-400" />
|
||||||
|
if (fileName.endsWith(".md")) return <FileText size={16} class="text-gray-400" />
|
||||||
|
if (fileName.endsWith(".png") || fileName.endsWith(".jpg"))
|
||||||
|
return <ImageIcon size={16} class="text-purple-400" />
|
||||||
|
return <FileCode size={16} class="text-blue-300" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTree: Component<{
|
||||||
|
node: FileNode;
|
||||||
|
depth: number;
|
||||||
|
onSelect: (f: FileNode) => void;
|
||||||
|
instanceId: string;
|
||||||
|
}> = (props) => {
|
||||||
|
const [isOpen, setIsOpen] = createSignal(props.depth === 0)
|
||||||
|
const [children, setChildren] = createSignal<FileNode[]>([])
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (props.node.type === "directory") {
|
||||||
|
const nextOpen = !isOpen()
|
||||||
|
setIsOpen(nextOpen)
|
||||||
|
|
||||||
|
if (nextOpen && children().length === 0) {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const entries = await serverApi.listWorkspaceFiles(props.instanceId, props.node.path)
|
||||||
|
setChildren(entries.map(e => ({
|
||||||
|
name: e.name,
|
||||||
|
type: e.type,
|
||||||
|
path: e.path
|
||||||
|
})))
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to list files", e)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
props.onSelect(props.node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-white/5 text-zinc-400 text-sm transition-colors rounded ${props.depth > 0 ? "ml-2" : ""}`}
|
||||||
|
>
|
||||||
|
<span class="mr-1 w-4 flex justify-center">
|
||||||
|
<Show when={props.node.type === "directory"}>
|
||||||
|
<Show when={isOpen()} fallback={<ChevronRight size={14} />}>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<span class="mr-2">
|
||||||
|
<Show
|
||||||
|
when={props.node.type === "directory"}
|
||||||
|
fallback={getFileIcon(props.node.name)}
|
||||||
|
>
|
||||||
|
<Folder size={14} class="text-blue-500/80" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<span class={props.node.type === "directory" ? "font-medium" : ""}>{props.node.name}</span>
|
||||||
|
<Show when={isLoading()}>
|
||||||
|
<span class="ml-2 w-3 h-3 border border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={props.node.type === "directory" && isOpen()}>
|
||||||
|
<div class="border-l border-white/5 ml-3">
|
||||||
|
<For each={children()}>
|
||||||
|
{(child) => <FileTree node={child} depth={props.depth + 1} onSelect={props.onSelect} instanceId={props.instanceId} />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: Component<SidebarProps> = (props) => {
|
||||||
|
const [activeTab, setActiveTab] = createSignal("files")
|
||||||
|
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
|
||||||
|
|
||||||
|
createEffect(async () => {
|
||||||
|
if (props.instanceId) {
|
||||||
|
try {
|
||||||
|
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
|
||||||
|
setRootFiles(entries.map(e => ({
|
||||||
|
name: e.name,
|
||||||
|
type: e.type,
|
||||||
|
path: e.path
|
||||||
|
})))
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load root files", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`flex bg-[#111111] border-r border-white/5 transition-all duration-300 ease-in-out h-full ${props.isOpen ? "w-72" : "w-0 overflow-hidden"}`}
|
||||||
|
>
|
||||||
|
{/* Activity Bar */}
|
||||||
|
<div class="w-14 border-r border-white/5 flex flex-col items-center py-4 space-y-6 shrink-0">
|
||||||
|
<For
|
||||||
|
each={[
|
||||||
|
{ id: "files", icon: Files },
|
||||||
|
{ id: "sessions", icon: User },
|
||||||
|
{ id: "search", icon: Search },
|
||||||
|
{ id: "git", icon: GitBranch },
|
||||||
|
{ id: "debug", icon: Play },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab(item.id)}
|
||||||
|
class={`p-2 transition-all duration-200 relative ${activeTab() === item.id ? "text-white" : "text-zinc-500 hover:text-zinc-300"}`}
|
||||||
|
>
|
||||||
|
<item.icon size={22} strokeWidth={1.5} />
|
||||||
|
<Show when={activeTab() === item.id}>
|
||||||
|
<div class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-6 bg-blue-500 rounded-r-full shadow-[0_0_10px_rgba(59,130,246,0.5)]" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<div class="mt-auto pb-2">
|
||||||
|
<button class="text-zinc-500 hover:text-white transition-colors">
|
||||||
|
<Settings size={22} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side Pane */}
|
||||||
|
<div class="flex-1 flex flex-col py-3 min-w-0">
|
||||||
|
<div class="px-4 mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-[10px] uppercase font-bold text-zinc-500 tracking-wider">
|
||||||
|
{activeTab() === "files" ? "Explorer" : activeTab() === "sessions" ? "Sessions" : activeTab()}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto px-2">
|
||||||
|
<Show when={activeTab() === "files"}>
|
||||||
|
<For each={rootFiles()}>
|
||||||
|
{(node) => <FileTree node={node} depth={0} onSelect={props.onFileSelect} instanceId={props.instanceId} />}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
<Show when={activeTab() === "sessions"}>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<For each={props.sessions}>
|
||||||
|
{(session) => (
|
||||||
|
<div
|
||||||
|
onClick={() => props.onSessionSelect(session.id)}
|
||||||
|
class={`px-3 py-1.5 rounded cursor-pointer text-sm transition-colors ${props.activeSessionId === session.id ? 'bg-blue-600/20 text-blue-400 border border-blue-500/20' : 'text-zinc-400 hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
{session.title || session.id.slice(0, 8)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Index, type Accessor } from "solid-js"
|
import { Index, type Accessor, createEffect } from "solid-js"
|
||||||
import VirtualItem from "./virtual-item"
|
import VirtualItem from "./virtual-item"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("multix-chat")
|
||||||
|
|
||||||
export function getMessageAnchorId(messageId: string) {
|
export function getMessageAnchorId(messageId: string) {
|
||||||
return `message-anchor-${messageId}`
|
return `message-anchor-${messageId}`
|
||||||
@@ -28,6 +31,14 @@ interface MessageBlockListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||||
|
createEffect(() => {
|
||||||
|
const ids = props.messageIds();
|
||||||
|
log.info("[MessageBlockList] messageIds changed", {
|
||||||
|
count: ids.length,
|
||||||
|
ids: ids.slice(-3) // Log last 3 for context
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Index each={props.messageIds()}>
|
<Index each={props.messageIds()}>
|
||||||
|
|||||||
95
packages/ui/src/components/model-status-selector.tsx
Normal file
95
packages/ui/src/components/model-status-selector.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
|
import { providers, fetchProviders } from "../stores/sessions"
|
||||||
|
import { ChevronDown, ShieldCheck, Cpu } from "lucide-solid"
|
||||||
|
import type { Model, Provider } from "../types/session"
|
||||||
|
import { Popover } from "@kobalte/core/popover"
|
||||||
|
|
||||||
|
interface ModelStatusSelectorProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
currentModel: { providerId: string; modelId: string }
|
||||||
|
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModelStatusSelector(props: ModelStatusSelectorProps) {
|
||||||
|
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false)
|
||||||
|
|
||||||
|
const currentProvider = createMemo(() =>
|
||||||
|
instanceProviders().find(p => p.id === props.currentModel.providerId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentModel = createMemo(() =>
|
||||||
|
currentProvider()?.models.find(m => m.id === props.currentModel.modelId)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simple auth status check: if we have providers and the current provider is in the list, we consider it "authenticated"
|
||||||
|
const isAuthenticated = createMemo(() => !!currentProvider())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{/* Auth Status Indicator */}
|
||||||
|
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-2 py-1 space-x-1.5 h-[26px]">
|
||||||
|
<div class={`w-1.5 h-1.5 rounded-full transition-all duration-500 ${isAuthenticated() ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]' : 'bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.4)]'}`} />
|
||||||
|
<span class="text-[9px] font-black uppercase tracking-widest text-zinc-500 whitespace-nowrap">
|
||||||
|
{isAuthenticated() ? 'AUTHED' : 'NO AUTH'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Selector HUD */}
|
||||||
|
<Popover open={isOpen()} onOpenChange={setIsOpen}>
|
||||||
|
<Popover.Trigger class="flex items-center bg-white/5 border border-white/5 rounded-full px-3 py-1 space-x-2 text-zinc-400 hover:border-white/10 hover:bg-white/10 transition-all group h-[26px]">
|
||||||
|
<Cpu size={12} class="text-indigo-400 shrink-0" />
|
||||||
|
<div class="flex flex-col items-start leading-none">
|
||||||
|
<span class="text-[8px] font-black text-zinc-500 uppercase tracking-widest">AI MODEL</span>
|
||||||
|
<span class="text-[10px] font-bold text-zinc-200 truncate max-w-[100px]">
|
||||||
|
{currentModel()?.name ?? currentProvider()?.name ?? "Select Model"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={10} class={`transition-transform duration-200 shrink-0 ${isOpen() ? 'rotate-180' : ''}`} />
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content class="z-[1000] min-w-[240px] bg-[#0c0c0d] border border-white/10 rounded-2xl shadow-2xl shadow-black/50 p-2 animate-in fade-in zoom-in-95 duration-200 origin-top">
|
||||||
|
<div class="max-h-[400px] overflow-y-auto custom-scrollbar no-scrollbar">
|
||||||
|
<For each={instanceProviders()}>
|
||||||
|
{(provider) => (
|
||||||
|
<div class="mb-2 last:mb-0">
|
||||||
|
<div class="px-2 py-1 text-[9px] font-black text-zinc-600 uppercase tracking-widest flex items-center justify-between border-b border-white/5 mb-1">
|
||||||
|
<span>{provider.name}</span>
|
||||||
|
<Show when={provider.id === props.currentModel.providerId}>
|
||||||
|
<ShieldCheck size={10} class="text-emerald-500/50" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<For each={provider.models}>
|
||||||
|
{(model) => (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await props.onModelChange({ providerId: provider.id, modelId: model.id })
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
class={`w-full flex items-center justify-between px-2 py-2 rounded-lg text-[11px] transition-all border ${
|
||||||
|
model.id === props.currentModel.modelId && provider.id === props.currentModel.providerId
|
||||||
|
? 'bg-indigo-500/15 text-indigo-400 border-indigo-500/20'
|
||||||
|
: 'text-zinc-400 hover:bg-white/5 border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span class="font-bold">{model.name}</span>
|
||||||
|
<Show when={model.id === props.currentModel.modelId && provider.id === props.currentModel.providerId}>
|
||||||
|
<div class="w-1 h-1 bg-indigo-400 rounded-full animate-pulse" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1087,7 +1087,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="prompt-input-field-container">
|
<div class="prompt-input-field-container">
|
||||||
<div class="prompt-input-field">
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||||
@@ -1103,7 +1102,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
rows={4}
|
rows={1}
|
||||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
@@ -1166,7 +1165,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
239
packages/ui/src/components/settings/OllamaCloudSettings.tsx
Normal file
239
packages/ui/src/components/settings/OllamaCloudSettings.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { Component, createSignal, onMount, Show } from 'solid-js'
|
||||||
|
import toast from 'solid-toast'
|
||||||
|
import { Button } from '@suid/material'
|
||||||
|
import { Cloud, CheckCircle, XCircle, Loader } from 'lucide-solid'
|
||||||
|
|
||||||
|
interface OllamaCloudConfig {
|
||||||
|
enabled: boolean
|
||||||
|
apiKey?: string
|
||||||
|
endpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OllamaCloudModelsResponse {
|
||||||
|
models: Array<{
|
||||||
|
name: string
|
||||||
|
size: string
|
||||||
|
digest: string
|
||||||
|
modified_at: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const OllamaCloudSettings: Component = () => {
|
||||||
|
const [config, setConfig] = createSignal<OllamaCloudConfig>({ enabled: false })
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
|
const [isTesting, setIsTesting] = createSignal(false)
|
||||||
|
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
||||||
|
const [models, setModels] = createSignal<string[]>([])
|
||||||
|
const [isLoadingModels, setIsLoadingModels] = createSignal(false)
|
||||||
|
|
||||||
|
// Load config on mount
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:6149/api/ollama/config')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setConfig(data.config)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Ollama config:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleConfigChange = (field: keyof OllamaCloudConfig, value: any) => {
|
||||||
|
setConfig(prev => ({ ...prev, [field]: value }))
|
||||||
|
setConnectionStatus('idle')
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:6149/api/ollama/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config())
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Ollama Cloud configuration saved', {
|
||||||
|
duration: 3000,
|
||||||
|
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to save config')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save Ollama Cloud configuration', {
|
||||||
|
duration: 5000,
|
||||||
|
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
setIsTesting(true)
|
||||||
|
setConnectionStatus('testing')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:6149/api/ollama/test', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setConnectionStatus(data.connected ? 'connected' : 'failed')
|
||||||
|
|
||||||
|
if (data.connected) {
|
||||||
|
toast.success('Successfully connected to Ollama Cloud', {
|
||||||
|
duration: 3000,
|
||||||
|
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load models after successful connection
|
||||||
|
loadModels()
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to connect to Ollama Cloud', {
|
||||||
|
duration: 3000,
|
||||||
|
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Connection test failed')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionStatus('failed')
|
||||||
|
toast.error('Connection test failed', {
|
||||||
|
duration: 3000,
|
||||||
|
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadModels = async () => {
|
||||||
|
setIsLoadingModels(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:6149/api/ollama/models/cloud')
|
||||||
|
if (response.ok) {
|
||||||
|
const data: OllamaCloudModelsResponse = await response.json()
|
||||||
|
setModels(data.models.map(model => model.name))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load models:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingModels(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (connectionStatus()) {
|
||||||
|
case 'testing':
|
||||||
|
return <Loader class="w-4 h-4 animate-spin" />
|
||||||
|
case 'connected':
|
||||||
|
return <CheckCircle class="w-4 h-4 text-green-500" />
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle class="w-4 h-4 text-red-500" />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6 p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<Cloud class="w-6 h-6" />
|
||||||
|
<h2 class="text-xl font-semibold">Ollama Cloud Integration</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{/* Enable/Disable Toggle */}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="font-medium">Enable Ollama Cloud</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config().enabled}
|
||||||
|
onChange={(e) => handleConfigChange('enabled', e.target.checked)}
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key */}
|
||||||
|
<div>
|
||||||
|
<label class="block font-medium mb-2">API Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your Ollama Cloud API key"
|
||||||
|
value={config().apiKey || ''}
|
||||||
|
onChange={(e) => handleConfigChange('apiKey', e.target.value)}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
disabled={!config().enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Endpoint */}
|
||||||
|
<div>
|
||||||
|
<label class="block font-medium mb-2">Endpoint</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://ollama.com"
|
||||||
|
value={config().endpoint || ''}
|
||||||
|
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
disabled={!config().enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Connection */}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={!config().enabled || isTesting()}
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{getStatusIcon()}
|
||||||
|
{isTesting() ? 'Testing...' : 'Test Connection'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Show when={connectionStatus() === 'connected'}>
|
||||||
|
<span class="text-green-600 text-sm">Connected successfully</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={connectionStatus() === 'failed'}>
|
||||||
|
<span class="text-red-600 text-sm">Connection failed</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Models */}
|
||||||
|
<Show when={models().length > 0}>
|
||||||
|
<div>
|
||||||
|
<label class="block font-medium mb-2">Available Cloud Models</label>
|
||||||
|
<div class="grid grid-cols-1 gap-2">
|
||||||
|
{models().map(model => (
|
||||||
|
<div class="p-3 border border-gray-200 rounded-md bg-gray-50">
|
||||||
|
<code class="text-sm font-mono">{model}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Save Configuration */}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={saveConfig}
|
||||||
|
disabled={isLoading()}
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading() ? <Loader class="w-4 h-4 animate-spin" /> : null}
|
||||||
|
Save Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OllamaCloudSettings
|
||||||
191
packages/ui/src/components/settings/QwenCodeSettings.tsx
Normal file
191
packages/ui/src/components/settings/QwenCodeSettings.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Component, createSignal, onMount, Show } from 'solid-js'
|
||||||
|
import toast from 'solid-toast'
|
||||||
|
import { Button } from '@suid/material'
|
||||||
|
import { User, CheckCircle, XCircle, Loader, LogOut, ExternalLink } from 'lucide-solid'
|
||||||
|
import { useQwenOAuth } from '../../lib/integrations/qwen-oauth'
|
||||||
|
|
||||||
|
interface QwenUser {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email?: string
|
||||||
|
avatar?: string
|
||||||
|
tier: string
|
||||||
|
limits: {
|
||||||
|
requests_per_day: number
|
||||||
|
requests_per_minute: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QwenCodeSettings: Component = () => {
|
||||||
|
const { isAuthenticated, user, isLoading, signIn, signOut, createApiClient } = useQwenOAuth()
|
||||||
|
const [isSigningOut, setIsSigningOut] = createSignal(false)
|
||||||
|
|
||||||
|
const handleSignIn = async () => {
|
||||||
|
try {
|
||||||
|
await signIn()
|
||||||
|
toast.success('Successfully authenticated with Qwen Code', {
|
||||||
|
duration: 3000,
|
||||||
|
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to authenticate with Qwen Code', {
|
||||||
|
duration: 5000,
|
||||||
|
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||||
|
})
|
||||||
|
console.error('Qwen OAuth error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
setIsSigningOut(true)
|
||||||
|
try {
|
||||||
|
signOut()
|
||||||
|
toast.success('Successfully signed out from Qwen Code', {
|
||||||
|
duration: 3000,
|
||||||
|
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to sign out from Qwen Code', {
|
||||||
|
duration: 5000,
|
||||||
|
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||||
|
})
|
||||||
|
console.error('Qwen signout error:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSigningOut(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRemainingRequests = (user: QwenUser) => {
|
||||||
|
return `${user.limits.requests_per_day} requests/day, ${user.limits.requests_per_minute}/min`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6 p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<User class="w-6 h-6" />
|
||||||
|
<h2 class="text-xl font-semibold">Qwen Code Integration</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authentication Status */}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Show
|
||||||
|
when={isAuthenticated()}
|
||||||
|
fallback={
|
||||||
|
/* Not Authenticated State */
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="mb-4">
|
||||||
|
<User class="w-12 h-12 mx-auto text-gray-400" />
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||||
|
Connect your Qwen Code account to access AI-powered coding assistance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSignIn}
|
||||||
|
disabled={isLoading()}
|
||||||
|
class="flex items-center gap-2 mx-auto"
|
||||||
|
>
|
||||||
|
{isLoading() ? <Loader class="w-4 h-4 animate-spin" /> : null}
|
||||||
|
Connect Qwen Code Account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="mt-4 text-sm text-gray-500">
|
||||||
|
<p>Get 2,000 free requests per day with Qwen OAuth</p>
|
||||||
|
<a
|
||||||
|
href="https://qwen.ai"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue-600 hover:underline inline-flex items-center gap-1 mt-2"
|
||||||
|
>
|
||||||
|
<ExternalLink class="w-3 h-3" />
|
||||||
|
Learn more about Qwen Code
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Authenticated State */}
|
||||||
|
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-12 h-12 bg-green-100 dark:bg-green-800 rounded-full flex items-center justify-center">
|
||||||
|
<User class="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-green-900 dark:text-green-100">
|
||||||
|
{user()?.username || 'Qwen User'}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-green-700 dark:text-green-300">
|
||||||
|
{user()?.email}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<span class="text-xs px-2 py-1 bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200 rounded-full">
|
||||||
|
{user()?.tier || 'Free'} Tier
|
||||||
|
</span>
|
||||||
|
<Show when={user()}>
|
||||||
|
<span class="text-xs text-green-600 dark:text-green-400">
|
||||||
|
{formatRemainingRequests(user()!)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleSignOut}
|
||||||
|
disabled={isSigningOut()}
|
||||||
|
size="small"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<LogOut class="w-4 h-4" />
|
||||||
|
{isSigningOut() ? 'Signing out...' : 'Sign Out'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Feature Information */}
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<h3 class="font-semibold mb-3">Available Features</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-3">
|
||||||
|
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||||
|
<h4 class="font-medium text-sm">Code Understanding & Editing</h4>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Query and edit large codebases beyond traditional context window limits
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||||
|
<h4 class="font-medium text-sm">Workflow Automation</h4>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Automate operational tasks like handling pull requests and complex rebases
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||||
|
<h4 class="font-medium text-sm">Vision Model Support</h4>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Automatically detect images and switch to vision-capable models for multimodal analysis
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Tips */}
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">Usage Tips</h3>
|
||||||
|
<ul class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||||
|
<li>• Use <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">/compress</code> to compress conversation history</li>
|
||||||
|
<li>• Use <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">/stats</code> to check token usage</li>
|
||||||
|
<li>• Vision models automatically switch when images are detected</li>
|
||||||
|
<li>• Configure behavior in <code class="bg-blue-100 dark:bg-blue-800 px-1 rounded">~/.qwen/settings.json</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QwenCodeSettings
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
@import './styles/messaging.css';
|
@import './styles/messaging.css';
|
||||||
@import './styles/panels.css';
|
@import './styles/panels.css';
|
||||||
@import './styles/markdown.css';
|
@import './styles/markdown.css';
|
||||||
|
@import './styles/tabs.css';
|
||||||
|
@import './styles/antigravity.css';
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -21,9 +21,25 @@ import { getLogger } from "./logger"
|
|||||||
|
|
||||||
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
|
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
|
||||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||||
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE ?? FALLBACK_API_BASE : FALLBACK_API_BASE
|
const DEFAULT_BASE = typeof window !== "undefined"
|
||||||
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
? (window.__CODENOMAD_API_BASE__ ??
|
||||||
|
(window.location?.protocol === "file:" ? FALLBACK_API_BASE : (RUNTIME_BASE === "null" || !RUNTIME_BASE || RUNTIME_BASE.startsWith("file:") ? FALLBACK_API_BASE : RUNTIME_BASE)))
|
||||||
|
: FALLBACK_API_BASE
|
||||||
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||||
|
|
||||||
|
function getApiOrigin(base: string): string {
|
||||||
|
try {
|
||||||
|
if (base.startsWith("http://") || base.startsWith("https://")) {
|
||||||
|
return new URL(base).origin
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return FALLBACK_API_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_ORIGIN = getApiOrigin(API_BASE)
|
||||||
|
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
||||||
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
||||||
|
|
||||||
export const CODENOMAD_API_BASE = API_BASE
|
export const CODENOMAD_API_BASE = API_BASE
|
||||||
@@ -32,9 +48,18 @@ function buildEventsUrl(base: string | undefined, path: string): string {
|
|||||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
if (base) {
|
|
||||||
|
let effectiveBase = base;
|
||||||
|
if (typeof window !== "undefined" && window.location.protocol === "file:") {
|
||||||
|
if (!effectiveBase || effectiveBase.startsWith("/") || effectiveBase.startsWith("file:")) {
|
||||||
|
effectiveBase = FALLBACK_API_BASE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveBase) {
|
||||||
|
const origin = getApiOrigin(effectiveBase)
|
||||||
const normalized = path.startsWith("/") ? path : `/${path}`
|
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||||
return `${base}${normalized}`
|
return `${origin}${normalized}`
|
||||||
}
|
}
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
@@ -51,7 +76,7 @@ function logHttp(message: string, context?: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
const url = API_BASE_ORIGIN ? new URL(path, API_BASE_ORIGIN).toString() : path
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(init?.headers ?? {}),
|
...(init?.headers ?? {}),
|
||||||
|
|||||||
486
packages/ui/src/lib/integrations/qwen-oauth.ts
Normal file
486
packages/ui/src/lib/integrations/qwen-oauth.ts
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
/**
|
||||||
|
* Qwen Code OAuth Integration
|
||||||
|
* Provides OAuth authentication and API access for Qwen Code
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||||
|
import { createSignal, onMount } from 'solid-js'
|
||||||
|
|
||||||
|
// Configuration schema
|
||||||
|
export interface QwenConfig {
|
||||||
|
clientId?: string
|
||||||
|
redirectUri?: string
|
||||||
|
scope?: string
|
||||||
|
baseUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QwenAuthToken {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
expires_in: number
|
||||||
|
refresh_token?: string
|
||||||
|
scope?: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QwenUser {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email?: string
|
||||||
|
avatar?: string
|
||||||
|
tier: string
|
||||||
|
limits: {
|
||||||
|
requests_per_day: number
|
||||||
|
requests_per_minute: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QwenOAuthState {
|
||||||
|
state: string
|
||||||
|
code_verifier: string
|
||||||
|
code_challenge: string
|
||||||
|
redirect_uri: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QwenOAuthManager {
|
||||||
|
private config: Required<QwenConfig>
|
||||||
|
private tokenStorageKey = 'qwen_oauth_token'
|
||||||
|
private userStorageKey = 'qwen_user_info'
|
||||||
|
|
||||||
|
constructor(config: QwenConfig = {}) {
|
||||||
|
this.config = {
|
||||||
|
clientId: config.clientId || 'qwen-code-client',
|
||||||
|
redirectUri: config.redirectUri || `${window.location.origin}/auth/qwen/callback`,
|
||||||
|
scope: config.scope || 'read write',
|
||||||
|
baseUrl: config.baseUrl || 'https://qwen.ai'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate OAuth URL for authentication
|
||||||
|
*/
|
||||||
|
async generateAuthUrl(): Promise<{ url: string; state: QwenOAuthState }> {
|
||||||
|
const state = await this.generateOAuthState()
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: this.config.clientId,
|
||||||
|
redirect_uri: this.config.redirectUri,
|
||||||
|
scope: this.config.scope,
|
||||||
|
state: state.state,
|
||||||
|
code_challenge: state.code_challenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
})
|
||||||
|
|
||||||
|
const authUrl = `${this.config.baseUrl}/oauth/authorize?${params.toString()}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: authUrl,
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for access token
|
||||||
|
*/
|
||||||
|
async exchangeCodeForToken(code: string, state: string): Promise<QwenAuthToken> {
|
||||||
|
const storedState = this.getOAuthState(state)
|
||||||
|
if (!storedState) {
|
||||||
|
throw new Error('Invalid OAuth state')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: this.config.clientId,
|
||||||
|
code,
|
||||||
|
redirect_uri: this.config.redirectUri,
|
||||||
|
code_verifier: storedState.code_verifier
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token exchange failed: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await response.json()
|
||||||
|
const token = this.parseTokenResponse(tokenData)
|
||||||
|
|
||||||
|
// Store token
|
||||||
|
this.storeToken(token)
|
||||||
|
this.clearOAuthState(state)
|
||||||
|
|
||||||
|
return token
|
||||||
|
} catch (error) {
|
||||||
|
this.clearOAuthState(state)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh access token using refresh token
|
||||||
|
*/
|
||||||
|
async refreshToken(): Promise<QwenAuthToken> {
|
||||||
|
const currentToken = this.getStoredToken()
|
||||||
|
if (!currentToken?.refresh_token) {
|
||||||
|
throw new Error('No refresh token available')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: this.config.clientId,
|
||||||
|
refresh_token: currentToken.refresh_token
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token refresh failed: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await response.json()
|
||||||
|
const token = this.parseTokenResponse(tokenData)
|
||||||
|
this.storeToken(token)
|
||||||
|
|
||||||
|
return token
|
||||||
|
} catch (error) {
|
||||||
|
// If refresh fails, clear stored token
|
||||||
|
this.clearToken()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authenticated user info from API
|
||||||
|
*/
|
||||||
|
async fetchUserInfo(): Promise<QwenUser> {
|
||||||
|
const token = await this.getValidToken()
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Not authenticated')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.baseUrl}/api/user`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token.access_token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch user info: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid access token (refresh if needed)
|
||||||
|
*/
|
||||||
|
async getValidToken(): Promise<QwenAuthToken | null> {
|
||||||
|
const token = this.getStoredToken()
|
||||||
|
if (!token) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired (with 5-minute buffer)
|
||||||
|
const now = Date.now()
|
||||||
|
const expiresAt = (token.created_at + token.expires_in) * 1000 - 300000 // 5 min buffer
|
||||||
|
|
||||||
|
if (now >= expiresAt) {
|
||||||
|
try {
|
||||||
|
return await this.refreshToken()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh token:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create authenticated HTTP client
|
||||||
|
*/
|
||||||
|
createApiClient(): AxiosInstance {
|
||||||
|
const axios = require('axios') as any
|
||||||
|
|
||||||
|
return axios.create({
|
||||||
|
baseURL: `${this.config.baseUrl}/api`,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make authenticated API request
|
||||||
|
*/
|
||||||
|
async makeAuthenticatedRequest<T>(
|
||||||
|
client: AxiosInstance,
|
||||||
|
config: any
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
const token = await this.getValidToken()
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Authentication required')
|
||||||
|
}
|
||||||
|
|
||||||
|
client.defaults.headers.common['Authorization'] = `Bearer ${token.access_token}`
|
||||||
|
return client.request(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out user
|
||||||
|
*/
|
||||||
|
signOut(): void {
|
||||||
|
this.clearToken()
|
||||||
|
this.clearUserInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is authenticated
|
||||||
|
*/
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
const token = this.getValidToken()
|
||||||
|
return token !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored user info
|
||||||
|
*/
|
||||||
|
getUserInfo(): QwenUser | null {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.userStorageKey)
|
||||||
|
return stored ? JSON.parse(stored) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store user info
|
||||||
|
*/
|
||||||
|
private storeUserInfo(user: QwenUser): void {
|
||||||
|
localStorage.setItem(this.userStorageKey, JSON.stringify(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear user info
|
||||||
|
*/
|
||||||
|
private clearUserInfo(): void {
|
||||||
|
localStorage.removeItem(this.userStorageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PKCE OAuth state
|
||||||
|
*/
|
||||||
|
private async generateOAuthState(): Promise<QwenOAuthState> {
|
||||||
|
const state = nanoid()
|
||||||
|
const code_verifier = this.generateCodeVerifier()
|
||||||
|
const code_challenge = await this.generateCodeChallenge(code_verifier)
|
||||||
|
|
||||||
|
const oauthState: QwenOAuthState = {
|
||||||
|
state,
|
||||||
|
code_verifier,
|
||||||
|
code_challenge,
|
||||||
|
redirect_uri: this.config.redirectUri
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store state temporarily
|
||||||
|
sessionStorage.setItem(`qwen_oauth_${state}`, JSON.stringify(oauthState))
|
||||||
|
|
||||||
|
return oauthState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored OAuth state
|
||||||
|
*/
|
||||||
|
private getOAuthState(state: string): QwenOAuthState | null {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(`qwen_oauth_${state}`)
|
||||||
|
return stored ? JSON.parse(stored) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear OAuth state
|
||||||
|
*/
|
||||||
|
private clearOAuthState(state: string): void {
|
||||||
|
sessionStorage.removeItem(`qwen_oauth_${state}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate code verifier for PKCE
|
||||||
|
*/
|
||||||
|
private generateCodeVerifier(): string {
|
||||||
|
const array = new Uint8Array(32)
|
||||||
|
crypto.getRandomValues(array)
|
||||||
|
return Array.from(array, byte => String.fromCharCode(byte)).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate code challenge for PKCE
|
||||||
|
*/
|
||||||
|
private async generateCodeChallenge(verifier: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(verifier)
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map(b => String.fromCharCode(b))
|
||||||
|
.join('')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse token response
|
||||||
|
*/
|
||||||
|
private parseTokenResponse(data: any): QwenAuthToken {
|
||||||
|
const token: QwenAuthToken = {
|
||||||
|
access_token: data.access_token,
|
||||||
|
token_type: data.token_type,
|
||||||
|
expires_in: data.expires_in,
|
||||||
|
refresh_token: data.refresh_token,
|
||||||
|
scope: data.scope,
|
||||||
|
created_at: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store token
|
||||||
|
*/
|
||||||
|
private storeToken(token: QwenAuthToken): void {
|
||||||
|
localStorage.setItem(this.tokenStorageKey, JSON.stringify(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored token
|
||||||
|
*/
|
||||||
|
private getStoredToken(): QwenAuthToken | null {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.tokenStorageKey)
|
||||||
|
return stored ? JSON.parse(stored) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear token
|
||||||
|
*/
|
||||||
|
private clearToken(): void {
|
||||||
|
localStorage.removeItem(this.tokenStorageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solid Hook for Qwen OAuth
|
||||||
|
export function useQwenOAuth(config?: QwenConfig) {
|
||||||
|
const authManager = () => new QwenOAuthManager(config)
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = createSignal(false)
|
||||||
|
const [user, setUser] = createSignal<QwenUser | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
|
|
||||||
|
// Check authentication status on mount
|
||||||
|
onMount(() => {
|
||||||
|
const manager = authManager()
|
||||||
|
if (manager.isAuthenticated()) {
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
const userInfo = manager.getUserInfo()
|
||||||
|
if (userInfo) {
|
||||||
|
setUser(userInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const signIn = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const manager = authManager()
|
||||||
|
const { url, state } = await manager.generateAuthUrl()
|
||||||
|
|
||||||
|
// Open popup window for OAuth
|
||||||
|
const popup = window.open(
|
||||||
|
url,
|
||||||
|
'qwen-oauth',
|
||||||
|
'width=500,height=600,scrollbars=yes,resizable=yes'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!popup) {
|
||||||
|
throw new Error('Failed to open OAuth popup')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for popup close
|
||||||
|
const checkClosed = setInterval(() => {
|
||||||
|
if (popup.closed) {
|
||||||
|
clearInterval(checkClosed)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
// Listen for message from popup
|
||||||
|
const messageListener = async (event: MessageEvent) => {
|
||||||
|
if (event.origin !== window.location.origin) return
|
||||||
|
|
||||||
|
if (event.data.type === 'QWEN_OAUTH_SUCCESS') {
|
||||||
|
const { code, state } = event.data
|
||||||
|
await manager.exchangeCodeForToken(code, state)
|
||||||
|
const userInfo = await manager.fetchUserInfo()
|
||||||
|
setUser(userInfo)
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setIsLoading(false)
|
||||||
|
popup.close()
|
||||||
|
} else if (event.data.type === 'QWEN_OAUTH_ERROR') {
|
||||||
|
setIsLoading(false)
|
||||||
|
popup.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', messageListener)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkClosed)
|
||||||
|
window.removeEventListener('message', messageListener)
|
||||||
|
setIsLoading(false)
|
||||||
|
}, 300000) // 5 minute timeout
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setIsLoading(false)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const signOut = () => {
|
||||||
|
const manager = authManager()
|
||||||
|
manager.signOut()
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createApiClient = () => {
|
||||||
|
return authManager().createApiClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: () => isAuthenticated(),
|
||||||
|
user: () => user(),
|
||||||
|
isLoading: () => isLoading(),
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
createApiClient
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import debug from "debug"
|
import debug from "debug"
|
||||||
|
|
||||||
export type LoggerNamespace = "sse" | "api" | "session" | "actions"
|
export type LoggerNamespace = "sse" | "api" | "session" | "actions" | "solo" | "multix-chat"
|
||||||
|
|
||||||
interface Logger {
|
interface Logger {
|
||||||
log: (...args: unknown[]) => void
|
log: (...args: unknown[]) => void
|
||||||
@@ -22,7 +22,7 @@ export interface LoggerControls {
|
|||||||
disableAllLoggers: () => void
|
disableAllLoggers: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const KNOWN_NAMESPACES: LoggerNamespace[] = ["sse", "api", "session", "actions"]
|
const KNOWN_NAMESPACES: LoggerNamespace[] = ["sse", "api", "session", "actions", "solo", "multix-chat"]
|
||||||
const STORAGE_KEY = "opencode:logger:namespaces"
|
const STORAGE_KEY = "opencode:logger:namespaces"
|
||||||
|
|
||||||
const namespaceLoggers = new Map<LoggerNamespace, Logger>()
|
const namespaceLoggers = new Map<LoggerNamespace, Logger>()
|
||||||
@@ -80,36 +80,33 @@ function buildLogger(namespace: LoggerNamespace): Logger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLogger(namespace: LoggerNamespace): Logger {
|
function getLogger(namespace: string): Logger {
|
||||||
if (!KNOWN_NAMESPACES.includes(namespace)) {
|
const ns = namespace as LoggerNamespace
|
||||||
throw new Error(`Unknown logger namespace: ${namespace}`)
|
if (!namespaceLoggers.has(ns)) {
|
||||||
|
namespaceLoggers.set(ns, buildLogger(ns))
|
||||||
}
|
}
|
||||||
if (!namespaceLoggers.has(namespace)) {
|
return namespaceLoggers.get(ns)!
|
||||||
namespaceLoggers.set(namespace, buildLogger(namespace))
|
|
||||||
}
|
|
||||||
return namespaceLoggers.get(namespace)!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function listLoggerNamespaces(): NamespaceState[] {
|
function listLoggerNamespaces(): NamespaceState[] {
|
||||||
return KNOWN_NAMESPACES.map((name) => ({ name, enabled: enabledNamespaces.has(name) }))
|
return Array.from(namespaceLoggers.keys()).map((name) => ({
|
||||||
|
name,
|
||||||
|
enabled: enabledNamespaces.has(name)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableLogger(namespace: LoggerNamespace): void {
|
function enableLogger(namespace: string): void {
|
||||||
if (!KNOWN_NAMESPACES.includes(namespace)) {
|
const ns = namespace as LoggerNamespace
|
||||||
throw new Error(`Unknown logger namespace: ${namespace}`)
|
if (enabledNamespaces.has(ns)) return
|
||||||
}
|
enabledNamespaces.add(ns)
|
||||||
if (enabledNamespaces.has(namespace)) return
|
|
||||||
enabledNamespaces.add(namespace)
|
|
||||||
persistEnabledNamespaces()
|
persistEnabledNamespaces()
|
||||||
applyEnabledNamespaces()
|
applyEnabledNamespaces()
|
||||||
}
|
}
|
||||||
|
|
||||||
function disableLogger(namespace: LoggerNamespace): void {
|
function disableLogger(namespace: string): void {
|
||||||
if (!KNOWN_NAMESPACES.includes(namespace)) {
|
const ns = namespace as LoggerNamespace
|
||||||
throw new Error(`Unknown logger namespace: ${namespace}`)
|
if (!enabledNamespaces.has(ns)) return
|
||||||
}
|
enabledNamespaces.delete(ns)
|
||||||
if (!enabledNamespaces.has(namespace)) return
|
|
||||||
enabledNamespaces.delete(namespace)
|
|
||||||
persistEnabledNamespaces()
|
persistEnabledNamespaces()
|
||||||
applyEnabledNamespaces()
|
applyEnabledNamespaces()
|
||||||
}
|
}
|
||||||
@@ -121,7 +118,7 @@ function disableAllLoggers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enableAllLoggers(): void {
|
function enableAllLoggers(): void {
|
||||||
KNOWN_NAMESPACES.forEach((namespace) => enabledNamespaces.add(namespace))
|
namespaceLoggers.forEach((_, ns) => enabledNamespaces.add(ns))
|
||||||
persistEnabledNamespaces()
|
persistEnabledNamespaces()
|
||||||
applyEnabledNamespaces()
|
applyEnabledNamespaces()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,8 @@ export interface RuntimeEnvironment {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electronAPI?: unknown
|
electronAPI?: any
|
||||||
__TAURI__?: {
|
__TAURI__?: any
|
||||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
|
||||||
event?: {
|
|
||||||
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
|
||||||
}
|
|
||||||
dialog?: {
|
|
||||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
|
||||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ConfigData = AppConfig
|
|||||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||||
messageHistory: [],
|
messageHistory: [],
|
||||||
agentModelSelections: {},
|
agentModelSelections: {},
|
||||||
|
sessionTasks: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDeepEqual(a: unknown, b: unknown): boolean {
|
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||||
@@ -155,10 +156,12 @@ export class ServerStorage {
|
|||||||
const source = data ?? DEFAULT_INSTANCE_DATA
|
const source = data ?? DEFAULT_INSTANCE_DATA
|
||||||
const messageHistory = Array.isArray(source.messageHistory) ? [...source.messageHistory] : []
|
const messageHistory = Array.isArray(source.messageHistory) ? [...source.messageHistory] : []
|
||||||
const agentModelSelections = { ...(source.agentModelSelections ?? {}) }
|
const agentModelSelections = { ...(source.agentModelSelections ?? {}) }
|
||||||
|
const sessionTasks = { ...(source.sessionTasks ?? {}) }
|
||||||
return {
|
return {
|
||||||
...source,
|
...source,
|
||||||
messageHistory,
|
messageHistory,
|
||||||
agentModelSelections,
|
agentModelSelections,
|
||||||
|
sessionTasks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
104
packages/ui/src/pages/QwenOAuthCallback.tsx
Normal file
104
packages/ui/src/pages/QwenOAuthCallback.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Component, createEffect, createSignal } from 'solid-js'
|
||||||
|
import { useNavigate } from '@solidjs/router'
|
||||||
|
|
||||||
|
interface CallbackData {
|
||||||
|
type: 'QWEN_OAUTH_SUCCESS' | 'QWEN_OAUTH_ERROR'
|
||||||
|
code?: string
|
||||||
|
state?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const QwenOAuthCallback: Component = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [status, setStatus] = createSignal<'loading' | 'success' | 'error'>('loading')
|
||||||
|
const [message, setMessage] = createSignal('')
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const code = urlParams.get('code')
|
||||||
|
const state = urlParams.get('state')
|
||||||
|
const error = urlParams.get('error')
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setStatus('error')
|
||||||
|
setMessage(`Authentication failed: ${error}`)
|
||||||
|
sendErrorToOpener(error, state)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code && state) {
|
||||||
|
setStatus('success')
|
||||||
|
setMessage('Authentication successful! You can close this window.')
|
||||||
|
sendSuccessToOpener(code, state)
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
setMessage('Invalid callback parameters')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendSuccessToOpener = (code: string, state: string) => {
|
||||||
|
if (window.opener) {
|
||||||
|
const data: CallbackData = {
|
||||||
|
type: 'QWEN_OAUTH_SUCCESS',
|
||||||
|
code,
|
||||||
|
state
|
||||||
|
}
|
||||||
|
window.opener.postMessage(data, window.location.origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendErrorToOpener = (error: string, state: string | null) => {
|
||||||
|
if (window.opener) {
|
||||||
|
const data: CallbackData = {
|
||||||
|
type: 'QWEN_OAUTH_ERROR',
|
||||||
|
error,
|
||||||
|
state: state || undefined
|
||||||
|
}
|
||||||
|
window.opener.postMessage(data, window.location.origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div class="max-w-md w-full mx-auto p-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
{status() === 'loading' && (
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
|
)}
|
||||||
|
{status() === 'success' && (
|
||||||
|
<div class="text-green-600">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 0116 0zm-1 11a1 1 0 00-2 0v-2a1 1 0 112 0v2a1 1 0 012 0zm9.077-7.908a.75.75 0 00-1.079-1.028l-7.142 7.142a.75.75 0 001.079 1.028l7.142-7.142a.75.75 0 00-1.079-1.028zM3.75 8a.75.75 0 011.5 0v4.5a.75.75 0 01-1.5 0V8zM14 9.5a.75.75 0 00-1.5 0v4.5a.75.75 0 001.5 0v-4.5z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Authentication Successful!</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status() === 'error' && (
|
||||||
|
<div class="text-red-600">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 0116 0zm3.707-9.293a1 1 0 00-1.414-1.414l-6 6a1 1 0 101.414 1.414l6-6a1 1 0 00-1.414-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Authentication Failed</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">{message()}</p>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<p>You can safely close this window.</p>
|
||||||
|
<button
|
||||||
|
onclick={() => window.close()}
|
||||||
|
class="mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Close Window
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QwenOAuthCallback
|
||||||
@@ -47,7 +47,7 @@ function getTauriBridge(): TauriBridge | null {
|
|||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const bridge = (window as { __TAURI__?: TauriBridge }).__TAURI__
|
const bridge = (window as any).__TAURI__ as TauriBridge | undefined
|
||||||
if (!bridge || !bridge.event || !bridge.invoke) {
|
if (!bridge || !bridge.event || !bridge.invoke) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,16 @@ function annotateDocument() {
|
|||||||
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
|
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface electronAPI {
|
||||||
|
onCliStatus: (callback: (data: CliStatus) => void) => () => void
|
||||||
|
onCliError: (callback: (data: { message: string }) => void) => () => void
|
||||||
|
getCliStatus: () => Promise<CliStatus>
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElectronAPI(): electronAPI | null {
|
||||||
|
return (window as any).electronAPI || null
|
||||||
|
}
|
||||||
|
|
||||||
function LoadingApp() {
|
function LoadingApp() {
|
||||||
const [phrase, setPhrase] = createSignal(pickPhrase())
|
const [phrase, setPhrase] = createSignal(pickPhrase())
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
@@ -70,10 +80,56 @@ function LoadingApp() {
|
|||||||
const changePhrase = () => setPhrase(pickPhrase(phrase()))
|
const changePhrase = () => setPhrase(pickPhrase(phrase()))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
console.info("[loading] mounted")
|
||||||
annotateDocument()
|
annotateDocument()
|
||||||
setPhrase(pickPhrase())
|
setPhrase(pickPhrase())
|
||||||
const unsubscribers: Array<() => void> = []
|
const unsubscribers: Array<() => void> = []
|
||||||
|
|
||||||
|
async function bootstrapElectron(api: electronAPI) {
|
||||||
|
console.info("[loading] bootstrapping electron")
|
||||||
|
try {
|
||||||
|
const statusUnsubscribe = api.onCliStatus((payload) => {
|
||||||
|
console.info("[loading] received cli status:", payload)
|
||||||
|
if (payload.state === "ready" && payload.url) {
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
// Navigate to main app, not CLI API
|
||||||
|
// In dev, main app is on localhost:3000, in production it's the same origin as loading screen
|
||||||
|
const mainAppUrl = runtimeEnv.host === "electron" ? window.location.origin.replace(/:\d+/, ":3000") : payload.url
|
||||||
|
navigateTo(mainAppUrl)
|
||||||
|
} else if (payload.state === "error" && payload.error) {
|
||||||
|
setError(payload.error)
|
||||||
|
setStatus("Encountered an issue")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorUnsubscribe = api.onCliError((payload) => {
|
||||||
|
console.info("[loading] received cli error:", payload)
|
||||||
|
if (payload.message) {
|
||||||
|
setError(payload.message)
|
||||||
|
setStatus("Encountered an issue")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
unsubscribers.push(statusUnsubscribe, errorUnsubscribe)
|
||||||
|
|
||||||
|
const initialStatus = await api.getCliStatus()
|
||||||
|
console.info("[loading] initial status:", initialStatus)
|
||||||
|
if (initialStatus?.state === "ready" && initialStatus.url) {
|
||||||
|
// Navigate to main app, not CLI API
|
||||||
|
const mainAppUrl = runtimeEnv.host === "electron" ? window.location.origin.replace(/:\d+/, ":3000") : initialStatus.url
|
||||||
|
navigateTo(mainAppUrl)
|
||||||
|
} else if (initialStatus?.state === "error" && initialStatus.error) {
|
||||||
|
setError(initialStatus.error)
|
||||||
|
setStatus("Encountered an issue")
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[loading] bootstrap error:", err)
|
||||||
|
setError(String(err))
|
||||||
|
setStatus("Encountered an issue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrapTauri(tauriBridge: TauriBridge | null) {
|
async function bootstrapTauri(tauriBridge: TauriBridge | null) {
|
||||||
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
|
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
|
||||||
return
|
return
|
||||||
@@ -119,8 +175,15 @@ function LoadingApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.info("[loading] runtimeHost:", runtimeEnv.host)
|
||||||
if (isTauriHost()) {
|
if (isTauriHost()) {
|
||||||
void bootstrapTauri(getTauriBridge())
|
void bootstrapTauri(getTauriBridge())
|
||||||
|
} else if (runtimeEnv.host === "electron") {
|
||||||
|
const api = getElectronAPI()
|
||||||
|
console.info("[loading] electronAPI available:", !!api)
|
||||||
|
if (api) {
|
||||||
|
void bootstrapElectron(api)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getLogger } from "../lib/logger"
|
|||||||
|
|
||||||
const log = getLogger("api")
|
const log = getLogger("api")
|
||||||
|
|
||||||
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {} }
|
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {}, sessionTasks: {} }
|
||||||
|
|
||||||
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
|
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
|
||||||
const loadPromises = new Map<string, Promise<void>>()
|
const loadPromises = new Map<string, Promise<void>>()
|
||||||
@@ -17,6 +17,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
|
|||||||
...source,
|
...source,
|
||||||
messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [],
|
messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [],
|
||||||
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
|
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
|
||||||
|
sessionTasks: { ...(source.sessionTasks ?? {}) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal, createMemo, batch } from "solid-js"
|
||||||
|
import { resetSteps } from "./solo-store"
|
||||||
import type { Instance, LogEntry } from "../types/instance"
|
import type { Instance, LogEntry } from "../types/instance"
|
||||||
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||||
import { sdkManager } from "../lib/sdk-manager"
|
import { sdkManager } from "../lib/sdk-manager"
|
||||||
@@ -34,6 +35,11 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
|||||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
||||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||||
|
|
||||||
|
function syncHasInstancesFlag() {
|
||||||
|
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
||||||
|
setHasInstances(readyExists)
|
||||||
|
}
|
||||||
interface DisconnectedInstanceInfo {
|
interface DisconnectedInstanceInfo {
|
||||||
id: string
|
id: string
|
||||||
folder: string
|
folder: string
|
||||||
@@ -68,7 +74,6 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
|||||||
updateInstance(descriptor.id, mapped)
|
updateInstance(descriptor.id, mapped)
|
||||||
} else {
|
} else {
|
||||||
addInstance(mapped)
|
addInstance(mapped)
|
||||||
setHasInstances(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (descriptor.status === "ready") {
|
if (descriptor.status === "ready") {
|
||||||
@@ -135,9 +140,6 @@ void (async function initializeWorkspaces() {
|
|||||||
try {
|
try {
|
||||||
const workspaces = await serverApi.fetchWorkspaces()
|
const workspaces = await serverApi.fetchWorkspaces()
|
||||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||||
if (workspaces.length === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to load workspaces", error)
|
log.error("Failed to load workspaces", error)
|
||||||
}
|
}
|
||||||
@@ -159,9 +161,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
|||||||
case "workspace.stopped":
|
case "workspace.stopped":
|
||||||
releaseInstanceResources(event.workspaceId)
|
releaseInstanceResources(event.workspaceId)
|
||||||
removeInstance(event.workspaceId)
|
removeInstance(event.workspaceId)
|
||||||
if (instances().size === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case "workspace.log":
|
case "workspace.log":
|
||||||
handleWorkspaceLog(event.entry)
|
handleWorkspaceLog(event.entry)
|
||||||
@@ -249,6 +248,8 @@ function addInstance(instance: Instance) {
|
|||||||
})
|
})
|
||||||
ensureLogContainer(instance.id)
|
ensureLogContainer(instance.id)
|
||||||
ensureLogStreamingState(instance.id)
|
ensureLogStreamingState(instance.id)
|
||||||
|
resetSteps(instance.id) // Initialize SOLO steps
|
||||||
|
syncHasInstancesFlag()
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateInstance(id: string, updates: Partial<Instance>) {
|
function updateInstance(id: string, updates: Partial<Instance>) {
|
||||||
@@ -260,6 +261,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
|
|||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
syncHasInstancesFlag()
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeInstance(id: string) {
|
function removeInstance(id: string) {
|
||||||
@@ -301,6 +303,7 @@ function removeInstance(id: string) {
|
|||||||
clearCacheForInstance(id)
|
clearCacheForInstance(id)
|
||||||
messageStoreBus.unregisterInstance(id)
|
messageStoreBus.unregisterInstance(id)
|
||||||
clearInstanceDraftPrompts(id)
|
clearInstanceDraftPrompts(id)
|
||||||
|
syncHasInstancesFlag()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
|
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
|
||||||
@@ -328,9 +331,6 @@ async function stopInstance(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeInstance(id)
|
removeInstance(id)
|
||||||
if (instances().size === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||||
@@ -590,9 +590,6 @@ async function acknowledgeDisconnectedInstance(): Promise<void> {
|
|||||||
log.error("Failed to stop disconnected instance", error)
|
log.error("Failed to stop disconnected instance", error)
|
||||||
} finally {
|
} finally {
|
||||||
setDisconnectedInstance(null)
|
setDisconnectedInstance(null)
|
||||||
if (instances().size === 0) {
|
|
||||||
setHasInstances(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ function createEmptyUsageState(): SessionUsageState {
|
|||||||
totalInputTokens: 0,
|
totalInputTokens: 0,
|
||||||
totalOutputTokens: 0,
|
totalOutputTokens: 0,
|
||||||
totalReasoningTokens: 0,
|
totalReasoningTokens: 0,
|
||||||
|
totalCacheReadTokens: 0,
|
||||||
|
totalCacheWriteTokens: 0,
|
||||||
totalCost: 0,
|
totalCost: 0,
|
||||||
actualUsageTokens: 0,
|
actualUsageTokens: 0,
|
||||||
latestMessageId: undefined,
|
latestMessageId: undefined,
|
||||||
@@ -154,6 +156,8 @@ function removeUsageEntry(state: SessionUsageState, messageId: string | undefine
|
|||||||
state.totalInputTokens -= existing.inputTokens
|
state.totalInputTokens -= existing.inputTokens
|
||||||
state.totalOutputTokens -= existing.outputTokens
|
state.totalOutputTokens -= existing.outputTokens
|
||||||
state.totalReasoningTokens -= existing.reasoningTokens
|
state.totalReasoningTokens -= existing.reasoningTokens
|
||||||
|
state.totalCacheReadTokens -= existing.cacheReadTokens
|
||||||
|
state.totalCacheWriteTokens -= existing.cacheWriteTokens
|
||||||
state.totalCost -= existing.cost
|
state.totalCost -= existing.cost
|
||||||
delete state.entries[messageId]
|
delete state.entries[messageId]
|
||||||
if (state.latestMessageId === messageId) {
|
if (state.latestMessageId === messageId) {
|
||||||
@@ -520,7 +524,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
draft.partIds = [...draft.partIds, partId]
|
draft.partIds = [...draft.partIds, partId]
|
||||||
}
|
}
|
||||||
const existing = draft.parts[partId]
|
const existing = draft.parts[partId]
|
||||||
const nextRevision = existing ? existing.revision + 1 : cloned.version ?? 0
|
const nextRevision = existing ? existing.revision + 1 : 0
|
||||||
draft.parts[partId] = {
|
draft.parts[partId] = {
|
||||||
id: partId,
|
id: partId,
|
||||||
data: cloned,
|
data: cloned,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
|||||||
let totalInputTokens = usage?.totalInputTokens ?? 0
|
let totalInputTokens = usage?.totalInputTokens ?? 0
|
||||||
let totalOutputTokens = usage?.totalOutputTokens ?? 0
|
let totalOutputTokens = usage?.totalOutputTokens ?? 0
|
||||||
let totalReasoningTokens = usage?.totalReasoningTokens ?? 0
|
let totalReasoningTokens = usage?.totalReasoningTokens ?? 0
|
||||||
|
let totalCacheReadTokens = usage?.totalCacheReadTokens ?? 0
|
||||||
|
let totalCacheWriteTokens = usage?.totalCacheWriteTokens ?? 0
|
||||||
let totalCost = usage?.totalCost ?? 0
|
let totalCost = usage?.totalCost ?? 0
|
||||||
let actualUsageTokens = usage?.actualUsageTokens ?? 0
|
let actualUsageTokens = usage?.actualUsageTokens ?? 0
|
||||||
|
|
||||||
@@ -44,6 +46,8 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
|||||||
totalInputTokens = previousInfo.inputTokens
|
totalInputTokens = previousInfo.inputTokens
|
||||||
totalOutputTokens = previousInfo.outputTokens
|
totalOutputTokens = previousInfo.outputTokens
|
||||||
totalReasoningTokens = previousInfo.reasoningTokens
|
totalReasoningTokens = previousInfo.reasoningTokens
|
||||||
|
totalCacheReadTokens = previousInfo.cacheReadTokens
|
||||||
|
totalCacheWriteTokens = previousInfo.cacheWriteTokens
|
||||||
totalCost = previousInfo.cost
|
totalCost = previousInfo.cost
|
||||||
actualUsageTokens = previousInfo.actualUsageTokens
|
actualUsageTokens = previousInfo.actualUsageTokens
|
||||||
}
|
}
|
||||||
@@ -129,6 +133,8 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
|||||||
inputTokens: totalInputTokens,
|
inputTokens: totalInputTokens,
|
||||||
outputTokens: totalOutputTokens,
|
outputTokens: totalOutputTokens,
|
||||||
reasoningTokens: totalReasoningTokens,
|
reasoningTokens: totalReasoningTokens,
|
||||||
|
cacheReadTokens: totalCacheReadTokens,
|
||||||
|
cacheWriteTokens: totalCacheWriteTokens,
|
||||||
actualUsageTokens,
|
actualUsageTokens,
|
||||||
modelOutputLimit,
|
modelOutputLimit,
|
||||||
contextAvailableTokens,
|
contextAvailableTokens,
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export interface SessionUsageState {
|
|||||||
totalInputTokens: number
|
totalInputTokens: number
|
||||||
totalOutputTokens: number
|
totalOutputTokens: number
|
||||||
totalReasoningTokens: number
|
totalReasoningTokens: number
|
||||||
|
totalCacheReadTokens: number
|
||||||
|
totalCacheWriteTokens: number
|
||||||
totalCost: number
|
totalCost: number
|
||||||
actualUsageTokens: number
|
actualUsageTokens: number
|
||||||
latestMessageId?: string
|
latestMessageId?: string
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
import { instances } from "./instances"
|
import { instances } from "./instances"
|
||||||
|
import { addTaskMessage } from "./task-actions"
|
||||||
|
|
||||||
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
|
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
|
||||||
import { sessions, withSession } from "./session-state"
|
import { sessions, withSession } from "./session-state"
|
||||||
@@ -59,7 +60,8 @@ async function sendMessage(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
attachments: any[] = [],
|
attachments: any[] = [],
|
||||||
): Promise<void> {
|
taskId?: string,
|
||||||
|
): Promise<string> {
|
||||||
const instance = instances().get(instanceId)
|
const instance = instances().get(instanceId)
|
||||||
if (!instance || !instance.client) {
|
if (!instance || !instance.client) {
|
||||||
throw new Error("Instance not ready")
|
throw new Error("Instance not ready")
|
||||||
@@ -72,6 +74,22 @@ async function sendMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageId = createId("msg")
|
const messageId = createId("msg")
|
||||||
|
|
||||||
|
// If taskId is provided, associate this message with the task and set it as active
|
||||||
|
if (taskId) {
|
||||||
|
addTaskMessage(instanceId, sessionId, taskId, messageId)
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
session.activeTaskId = taskId
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// If no taskId, we might want to clear activeTaskId to go back to global chat
|
||||||
|
// or keep it if we are still "in" a task view.
|
||||||
|
// For isolation, it's better to clear it if a global message is sent.
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
session.activeTaskId = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const textPartId = createId("part")
|
const textPartId = createId("part")
|
||||||
|
|
||||||
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
||||||
@@ -143,6 +161,8 @@ async function sendMessage(
|
|||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const createdAt = Date.now()
|
const createdAt = Date.now()
|
||||||
|
|
||||||
|
log.info("sendMessage: upserting optimistic message", { messageId, sessionId, taskId });
|
||||||
|
|
||||||
store.upsertMessage({
|
store.upsertMessage({
|
||||||
id: messageId,
|
id: messageId,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -177,22 +197,43 @@ async function sendMessage(
|
|||||||
requestBody,
|
requestBody,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Electron diagnostic logging
|
||||||
|
if (typeof window !== "undefined" && (window as any).electron) {
|
||||||
|
log.info("Electron environment detected", {
|
||||||
|
isElectron: true,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
origin: window.location.origin
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
log.info("session.promptAsync starting", { instanceId, sessionId })
|
||||||
const response = await instance.client.session.promptAsync({
|
const response = await instance.client.session.promptAsync({
|
||||||
path: { id: sessionId },
|
path: { id: sessionId },
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
})
|
})
|
||||||
|
log.info("session.promptAsync success", { instanceId, sessionId, response })
|
||||||
|
return messageId
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error("Failed to send prompt", {
|
||||||
|
instanceId,
|
||||||
|
sessionId,
|
||||||
|
error: error?.message || error,
|
||||||
|
stack: error?.stack,
|
||||||
|
requestBody
|
||||||
|
})
|
||||||
|
|
||||||
log.info("sendMessage response", response)
|
// Update message status to error in store
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.upsertMessage({
|
||||||
|
id: messageId,
|
||||||
|
sessionId,
|
||||||
|
status: "error",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
log.error("sendMessage server error", response.error)
|
|
||||||
throw new Error(JSON.stringify(response.error) || "Failed to send message")
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to send prompt", error)
|
|
||||||
throw error
|
throw error
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,10 +404,65 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function revertSession(instanceId: string, sessionId: string): Promise<void> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance || !instance.client) {
|
||||||
|
throw new Error("Instance not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessions().get(instanceId)?.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
throw new Error("Session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await instance.client.session.revert({
|
||||||
|
path: { id: sessionId },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to revert session", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forkSession(instanceId: string, sessionId: string): Promise<string> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance || !instance.client) {
|
||||||
|
throw new Error("Instance not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessions().get(instanceId)?.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
throw new Error("Session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await instance.client.session.fork({
|
||||||
|
path: { id: sessionId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(JSON.stringify(response.error) || "Failed to fork session")
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSessionId = response.data?.id
|
||||||
|
if (!newSessionId) {
|
||||||
|
throw new Error("No session ID returned from fork operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSessionId
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to fork session", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
abortSession,
|
abortSession,
|
||||||
executeCustomCommand,
|
executeCustomCommand,
|
||||||
|
forkSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
|
revertSession,
|
||||||
runShellCommand,
|
runShellCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
updateSessionAgent,
|
updateSessionAgent,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
setLoading,
|
setLoading,
|
||||||
cleanupBlankSessions,
|
cleanupBlankSessions,
|
||||||
} from "./session-state"
|
} from "./session-state"
|
||||||
|
import { getInstanceConfig, ensureInstanceConfigLoaded } from "./instance-config"
|
||||||
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||||
import { updateSessionInfo } from "./message-v2/session-info"
|
import { updateSessionInfo } from "./message-v2/session-info"
|
||||||
@@ -79,6 +80,11 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||||||
|
|
||||||
const existingSessions = sessions().get(instanceId)
|
const existingSessions = sessions().get(instanceId)
|
||||||
|
|
||||||
|
// Load session tasks from storage
|
||||||
|
await ensureInstanceConfigLoaded(instanceId)
|
||||||
|
const instanceData = getInstanceConfig(instanceId)
|
||||||
|
const sessionTasks = instanceData.sessionTasks || {}
|
||||||
|
|
||||||
for (const apiSession of response.data) {
|
for (const apiSession of response.data) {
|
||||||
const existingSession = existingSessions?.get(apiSession.id)
|
const existingSession = existingSessions?.get(apiSession.id)
|
||||||
|
|
||||||
@@ -101,6 +107,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
|||||||
diff: apiSession.revert.diff,
|
diff: apiSession.revert.diff,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
tasks: sessionTasks[apiSession.id] || [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +233,8 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
|||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
reasoningTokens: 0,
|
reasoningTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
actualUsageTokens: 0,
|
actualUsageTokens: 0,
|
||||||
modelOutputLimit: initialOutputLimit,
|
modelOutputLimit: initialOutputLimit,
|
||||||
contextAvailableTokens: initialContextAvailable,
|
contextAvailableTokens: initialContextAvailable,
|
||||||
@@ -284,7 +293,7 @@ async function forkSession(
|
|||||||
id: info.id,
|
id: info.id,
|
||||||
instanceId,
|
instanceId,
|
||||||
title: info.title || "Forked Session",
|
title: info.title || "Forked Session",
|
||||||
parentId: info.parentID || null,
|
parentId: info.parentID || sourceSessionId, // Fallback to source session to ensure parent-child relationship
|
||||||
agent: info.agent || "",
|
agent: info.agent || "",
|
||||||
model: {
|
model: {
|
||||||
providerId: info.model?.providerID || "",
|
providerId: info.model?.providerID || "",
|
||||||
@@ -329,6 +338,8 @@ async function forkSession(
|
|||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
reasoningTokens: 0,
|
reasoningTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
actualUsageTokens: 0,
|
actualUsageTokens: 0,
|
||||||
modelOutputLimit: forkOutputLimit,
|
modelOutputLimit: forkOutputLimit,
|
||||||
contextAvailableTokens: forkContextAvailable,
|
contextAvailableTokens: forkContextAvailable,
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ import type { MessageStatus } from "./message-v2/types"
|
|||||||
|
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||||
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
|
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
|
||||||
|
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store"
|
||||||
|
import { sendMessage } from "./session-actions"
|
||||||
import { showAlertDialog } from "./alerts"
|
import { showAlertDialog } from "./alerts"
|
||||||
import { sessions, setSessions, withSession } from "./session-state"
|
import { sessions, setSessions, withSession } from "./session-state"
|
||||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||||
import { updateSessionInfo } from "./message-v2/session-info"
|
import { updateSessionInfo } from "./message-v2/session-info"
|
||||||
|
import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
|
||||||
|
|
||||||
const log = getLogger("sse")
|
const log = getLogger("sse")
|
||||||
import { loadMessages } from "./session-api"
|
import { loadMessages } from "./session-api"
|
||||||
@@ -89,7 +92,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
if (!sessionId || !messageId) return
|
if (!sessionId || !messageId) return
|
||||||
|
|
||||||
const session = instanceSessions.get(sessionId)
|
const session = instanceSessions.get(sessionId)
|
||||||
if (!session) return
|
// Note: session may be null for newly forked sessions where SSE event arrives before session is registered
|
||||||
|
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const role: MessageRole = resolveMessageRole(messageInfo)
|
const role: MessageRole = resolveMessageRole(messageInfo)
|
||||||
@@ -101,6 +104,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||||
if (pendingId && pendingId !== messageId) {
|
if (pendingId && pendingId !== messageId) {
|
||||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||||
|
replaceTaskMessageId(instanceId, sessionId, pendingId, messageId)
|
||||||
record = store.getMessage(messageId)
|
record = store.getMessage(messageId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +119,31 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
updatedAt: createdAt,
|
updatedAt: createdAt,
|
||||||
isEphemeral: true,
|
isEphemeral: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Try to associate message with task
|
||||||
|
if (session?.activeTaskId) {
|
||||||
|
addTaskMessage(instanceId, sessionId, session.activeTaskId, messageId)
|
||||||
|
} else if (session?.parentId) {
|
||||||
|
// This is a task session. Find the parent and update the task.
|
||||||
|
const parentSession = instanceSessions.get(session.parentId)
|
||||||
|
if (parentSession?.tasks) {
|
||||||
|
const task = parentSession.tasks.find((t) => t.taskSessionId === sessionId)
|
||||||
|
if (task) {
|
||||||
|
addTaskMessage(instanceId, session.parentId, task.id, messageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!session) {
|
||||||
|
// Session not found yet - search all sessions for a task with this sessionId
|
||||||
|
for (const [, candidateSession] of instanceSessions) {
|
||||||
|
if (candidateSession.tasks) {
|
||||||
|
const task = candidateSession.tasks.find((t) => t.taskSessionId === sessionId)
|
||||||
|
if (task) {
|
||||||
|
addTaskMessage(instanceId, candidateSession.id, task.id, messageId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageInfo) {
|
if (messageInfo) {
|
||||||
@@ -134,18 +163,30 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
if (!sessionId || !messageId) return
|
if (!sessionId || !messageId) return
|
||||||
|
|
||||||
const session = instanceSessions.get(sessionId)
|
const session = instanceSessions.get(sessionId)
|
||||||
if (!session) return
|
// Note: session may be null for newly forked sessions where SSE event arrives before session is registered
|
||||||
|
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const role: MessageRole = info.role === "user" ? "user" : "assistant"
|
const role: MessageRole = info.role === "user" ? "user" : "assistant"
|
||||||
const hasError = Boolean((info as any).error)
|
const hasError = Boolean((info as any).error)
|
||||||
const status: MessageStatus = hasError ? "error" : "complete"
|
const status: MessageStatus = hasError ? "error" : "complete"
|
||||||
|
|
||||||
|
// Auto-correction logic for SOLO
|
||||||
|
const solo = getSoloState(instanceId)
|
||||||
|
if (hasError && solo.isAutonomous && solo.currentStep < solo.maxSteps) {
|
||||||
|
log.info(`[SOLO] Error detected in autonomous mode, prompting for fix: ${messageId}`)
|
||||||
|
const errorMessage = (info as any).error?.message || "Unknown error"
|
||||||
|
incrementStep(instanceId)
|
||||||
|
sendMessage(instanceId, sessionId, `The previous step failed with error: ${errorMessage}. Please analyze the error and try a different approach.`, [], solo.activeTaskId || undefined).catch((err) => {
|
||||||
|
log.error("[SOLO] Failed to send error correction message", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let record = store.getMessage(messageId)
|
let record = store.getMessage(messageId)
|
||||||
if (!record) {
|
if (!record) {
|
||||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||||
if (pendingId && pendingId !== messageId) {
|
if (pendingId && pendingId !== messageId) {
|
||||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||||
|
replaceTaskMessageId(instanceId, sessionId, pendingId, messageId)
|
||||||
record = store.getMessage(messageId)
|
record = store.getMessage(messageId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +202,31 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: completedAt ?? createdAt,
|
updatedAt: completedAt ?? createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Try to associate message with task
|
||||||
|
if (session?.activeTaskId) {
|
||||||
|
addTaskMessage(instanceId, sessionId, session.activeTaskId, messageId)
|
||||||
|
} else if (session?.parentId) {
|
||||||
|
// This is a task session. Find the parent and update the task.
|
||||||
|
const parentSession = instanceSessions.get(session.parentId)
|
||||||
|
if (parentSession?.tasks) {
|
||||||
|
const task = parentSession.tasks.find((t) => t.taskSessionId === sessionId)
|
||||||
|
if (task) {
|
||||||
|
addTaskMessage(instanceId, session.parentId, task.id, messageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!session) {
|
||||||
|
// Session not found yet - search all sessions for a task with this sessionId
|
||||||
|
for (const [, candidateSession] of instanceSessions) {
|
||||||
|
if (candidateSession.tasks) {
|
||||||
|
const task = candidateSession.tasks.find((t) => t.taskSessionId === sessionId)
|
||||||
|
if (task) {
|
||||||
|
addTaskMessage(instanceId, candidateSession.id, task.id, messageId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
|
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
|
||||||
@@ -198,9 +264,9 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||||||
time: info.time
|
time: info.time
|
||||||
? { ...info.time }
|
? { ...info.time }
|
||||||
: {
|
: {
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
updated: Date.now(),
|
updated: Date.now(),
|
||||||
},
|
},
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
setSessions((prev) => {
|
setSessions((prev) => {
|
||||||
@@ -228,11 +294,11 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||||||
time: mergedTime,
|
time: mergedTime,
|
||||||
revert: info.revert
|
revert: info.revert
|
||||||
? {
|
? {
|
||||||
messageID: info.revert.messageID,
|
messageID: info.revert.messageID,
|
||||||
partID: info.revert.partID,
|
partID: info.revert.partID,
|
||||||
snapshot: info.revert.snapshot,
|
snapshot: info.revert.snapshot,
|
||||||
diff: info.revert.diff,
|
diff: info.revert.diff,
|
||||||
}
|
}
|
||||||
: existingSession.revert,
|
: existingSession.revert,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,11 +313,50 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
|
function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
||||||
const sessionId = event.properties?.sessionID
|
const sessionId = event.properties?.sessionID
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
|
||||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||||
|
|
||||||
|
// Autonomous continuation logic for SOLO
|
||||||
|
const solo = getSoloState(instanceId)
|
||||||
|
if (solo.isAutonomous && solo.currentStep < solo.maxSteps) {
|
||||||
|
const instanceSessions = sessions().get(instanceId)
|
||||||
|
const session = instanceSessions?.get(sessionId)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
// If there's an active task, we might want to prompt the agent to continue or check progress
|
||||||
|
if (solo.activeTaskId) {
|
||||||
|
log.info(`[SOLO] Session idle in autonomous mode, prompting continuation for task: ${solo.activeTaskId}`)
|
||||||
|
incrementStep(instanceId)
|
||||||
|
sendMessage(instanceId, sessionId, "Continue", [], solo.activeTaskId).catch((err) => {
|
||||||
|
log.error("[SOLO] Failed to send continuation message", err)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Check if there's another task in the queue
|
||||||
|
const nextTaskId = popFromTaskQueue(instanceId)
|
||||||
|
if (nextTaskId) {
|
||||||
|
log.info(`[SOLO] Session idle, starting next task from queue: ${nextTaskId}`)
|
||||||
|
|
||||||
|
// Find the task title to provide context
|
||||||
|
let taskTitle = "Start next task"
|
||||||
|
const instanceSessions = sessions().get(instanceId)
|
||||||
|
const session = instanceSessions?.get(sessionId)
|
||||||
|
if (session?.tasks) {
|
||||||
|
const task = session.tasks.find(t => t.id === nextTaskId)
|
||||||
|
if (task) {
|
||||||
|
taskTitle = `Please start working on the task: "${task.title}". Provide a plan and begin execution.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTaskId(instanceId, nextTaskId)
|
||||||
|
sendMessage(instanceId, sessionId, taskTitle, [], nextTaskId).catch((err) => {
|
||||||
|
log.error("[SOLO] Failed to start next task", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
|
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
|
||||||
@@ -284,7 +389,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSessionError(_instanceId: string, event: EventSessionError): void {
|
function handleSessionError(instanceId: string, event: EventSessionError): void {
|
||||||
const error = event.properties?.error
|
const error = event.properties?.error
|
||||||
log.error(`[SSE] Session error:`, error)
|
log.error(`[SSE] Session error:`, error)
|
||||||
|
|
||||||
@@ -298,10 +403,21 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showAlertDialog(`Error: ${message}`, {
|
// Autonomous error recovery for SOLO
|
||||||
title: "Session error",
|
const solo = getSoloState(instanceId)
|
||||||
variant: "error",
|
const sessionId = (event.properties as any)?.sessionID
|
||||||
})
|
if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) {
|
||||||
|
log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`)
|
||||||
|
incrementStep(instanceId)
|
||||||
|
sendMessage(instanceId, sessionId, `I encountered an error: "${message}". Please analyze the cause and provide a fix.`, [], solo.activeTaskId || undefined).catch((err) => {
|
||||||
|
log.error("[SOLO] Failed to send error recovery message", err)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showAlertDialog(`Error: ${message}`, {
|
||||||
|
title: "Session error",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
|
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
|
||||||
@@ -344,6 +460,18 @@ function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdat
|
|||||||
log.info(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
log.info(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||||
addPermissionToQueue(instanceId, permission)
|
addPermissionToQueue(instanceId, permission)
|
||||||
upsertPermissionV2(instanceId, permission)
|
upsertPermissionV2(instanceId, permission)
|
||||||
|
|
||||||
|
// Auto-approval logic for SOLO autonomous agent
|
||||||
|
const solo = getSoloState(instanceId)
|
||||||
|
if (solo.isAutonomous && solo.autoApproval) {
|
||||||
|
log.info(`[SOLO] Auto-approving permission: ${permission.id}`)
|
||||||
|
const sessionId = permission.sessionID
|
||||||
|
if (sessionId) {
|
||||||
|
sendPermissionResponse(instanceId, sessionId, permission.id, "always").catch((err) => {
|
||||||
|
log.error(`[SOLO] Failed to auto-approve permission ${permission.id}`, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { messageStoreBus } from "./message-v2/bus"
|
|||||||
import { instances } from "./instances"
|
import { instances } from "./instances"
|
||||||
import { showConfirmDialog } from "./alerts"
|
import { showConfirmDialog } from "./alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { updateInstanceConfig } from "./instance-config"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ export interface SessionInfo {
|
|||||||
inputTokens: number
|
inputTokens: number
|
||||||
outputTokens: number
|
outputTokens: number
|
||||||
reasoningTokens: number
|
reasoningTokens: number
|
||||||
|
cacheReadTokens: number
|
||||||
|
cacheWriteTokens: number
|
||||||
actualUsageTokens: number
|
actualUsageTokens: number
|
||||||
modelOutputLimit: number
|
modelOutputLimit: number
|
||||||
contextAvailableTokens: number | null
|
contextAvailableTokens: number | null
|
||||||
@@ -150,6 +153,29 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S
|
|||||||
next.set(instanceId, newInstanceSessions)
|
next.set(instanceId, newInstanceSessions)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Persist session tasks to storage
|
||||||
|
persistSessionTasks(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSessionTasks(instanceId: string) {
|
||||||
|
try {
|
||||||
|
const instanceSessions = sessions().get(instanceId)
|
||||||
|
if (!instanceSessions) return
|
||||||
|
|
||||||
|
const sessionTasks: Record<string, any[]> = {}
|
||||||
|
for (const [sessionId, session] of instanceSessions) {
|
||||||
|
if (session.tasks && session.tasks.length > 0) {
|
||||||
|
sessionTasks[sessionId] = session.tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateInstanceConfig(instanceId, (draft) => {
|
||||||
|
draft.sessionTasks = sessionTasks
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to persist session tasks", error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
|
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
|
||||||
@@ -378,6 +404,7 @@ export {
|
|||||||
clearInstanceDraftPrompts,
|
clearInstanceDraftPrompts,
|
||||||
pruneDraftPrompts,
|
pruneDraftPrompts,
|
||||||
withSession,
|
withSession,
|
||||||
|
persistSessionTasks,
|
||||||
setSessionCompactionState,
|
setSessionCompactionState,
|
||||||
setSessionPendingPermission,
|
setSessionPendingPermission,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
|
|||||||
77
packages/ui/src/stores/solo-store.ts
Normal file
77
packages/ui/src/stores/solo-store.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("solo")
|
||||||
|
|
||||||
|
export interface SoloState {
|
||||||
|
isAutonomous: boolean
|
||||||
|
autoApproval: boolean
|
||||||
|
maxSteps: number
|
||||||
|
currentStep: number
|
||||||
|
activeTaskId: string | null
|
||||||
|
taskQueue: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [soloStates, setSoloStates] = createSignal<Map<string, SoloState>>(new Map())
|
||||||
|
|
||||||
|
export function getSoloState(instanceId: string): SoloState {
|
||||||
|
const state = soloStates().get(instanceId)
|
||||||
|
if (!state) {
|
||||||
|
return {
|
||||||
|
isAutonomous: false,
|
||||||
|
autoApproval: false,
|
||||||
|
maxSteps: 50,
|
||||||
|
currentStep: 0,
|
||||||
|
activeTaskId: null,
|
||||||
|
taskQueue: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSoloState(instanceId: string, partial: Partial<SoloState>) {
|
||||||
|
setSoloStates((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const current = getSoloState(instanceId)
|
||||||
|
next.set(instanceId, { ...current, ...partial })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleAutonomous(instanceId: string) {
|
||||||
|
const current = getSoloState(instanceId)
|
||||||
|
setSoloState(instanceId, { isAutonomous: !current.isAutonomous })
|
||||||
|
log.info(`Autonomous mode ${!current.isAutonomous ? "enabled" : "disabled"} for ${instanceId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleAutoApproval(instanceId: string) {
|
||||||
|
const current = getSoloState(instanceId)
|
||||||
|
setSoloState(instanceId, { autoApproval: !current.autoApproval })
|
||||||
|
log.info(`Auto-approval ${!current.autoApproval ? "enabled" : "disabled"} for ${instanceId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementStep(instanceId: string) {
|
||||||
|
const state = getSoloState(instanceId)
|
||||||
|
setSoloState(instanceId, { currentStep: state.currentStep + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSteps(instanceId: string) {
|
||||||
|
setSoloState(instanceId, { currentStep: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActiveTaskId(instanceId: string, taskId: string | null) {
|
||||||
|
setSoloState(instanceId, { activeTaskId: taskId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToTaskQueue(instanceId: string, taskId: string) {
|
||||||
|
const current = getSoloState(instanceId)
|
||||||
|
setSoloState(instanceId, { taskQueue: [...current.taskQueue, taskId] })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function popFromTaskQueue(instanceId: string): string | null {
|
||||||
|
const current = getSoloState(instanceId)
|
||||||
|
if (current.taskQueue.length === 0) return null
|
||||||
|
const [next, ...rest] = current.taskQueue
|
||||||
|
setSoloState(instanceId, { taskQueue: rest })
|
||||||
|
return next
|
||||||
|
}
|
||||||
163
packages/ui/src/stores/task-actions.ts
Normal file
163
packages/ui/src/stores/task-actions.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { withSession } from "./session-state"
|
||||||
|
import { Task, TaskStatus } from "../types/session"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
import { forkSession } from "./session-api"
|
||||||
|
|
||||||
|
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
session.activeTaskId = taskId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTask(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
title: string
|
||||||
|
): Promise<{ id: string; taskSessionId?: string }> {
|
||||||
|
const id = nanoid()
|
||||||
|
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
|
||||||
|
|
||||||
|
let taskSessionId: string | undefined
|
||||||
|
try {
|
||||||
|
console.log("[task-actions] forking session...");
|
||||||
|
const forked = await forkSession(instanceId, sessionId)
|
||||||
|
taskSessionId = forked.id
|
||||||
|
console.log("[task-actions] fork successful", { taskSessionId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[task-actions] Failed to fork session for task", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTask: Task = {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
status: "pending",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
messageIds: [],
|
||||||
|
taskSessionId,
|
||||||
|
}
|
||||||
|
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
if (!session.tasks) {
|
||||||
|
session.tasks = []
|
||||||
|
}
|
||||||
|
session.tasks = [newTask, ...session.tasks]
|
||||||
|
console.log("[task-actions] task added to session", { taskCount: session.tasks.length });
|
||||||
|
})
|
||||||
|
|
||||||
|
return { id, taskSessionId }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTaskMessage(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string,
|
||||||
|
messageId: string,
|
||||||
|
): void {
|
||||||
|
console.log("[task-actions] addTaskMessage called", { instanceId, sessionId, taskId, messageId });
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
let targetSessionId = sessionId
|
||||||
|
let targetTaskId = taskId
|
||||||
|
|
||||||
|
// If this is a child session, the tasks are on the parent
|
||||||
|
if (session.parentId && !session.tasks) {
|
||||||
|
targetSessionId = session.parentId
|
||||||
|
console.log("[task-actions] task session detected, targeting parent", { parentId: session.parentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
withSession(instanceId, targetSessionId, (targetSession) => {
|
||||||
|
if (!targetSession.tasks) {
|
||||||
|
console.warn("[task-actions] target session has no tasks array", { targetSessionId });
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskIndex = targetSession.tasks.findIndex((t) => t.id === targetTaskId || t.taskSessionId === sessionId)
|
||||||
|
if (taskIndex !== -1) {
|
||||||
|
const task = targetSession.tasks[taskIndex]
|
||||||
|
const messageIds = [...(task.messageIds || [])]
|
||||||
|
|
||||||
|
if (!messageIds.includes(messageId)) {
|
||||||
|
messageIds.push(messageId)
|
||||||
|
|
||||||
|
// Replace the task object and the tasks array to trigger reactivity
|
||||||
|
const updatedTask = { ...task, messageIds }
|
||||||
|
const updatedTasks = [...targetSession.tasks]
|
||||||
|
updatedTasks[taskIndex] = updatedTask
|
||||||
|
targetSession.tasks = updatedTasks
|
||||||
|
|
||||||
|
console.log("[task-actions] message ID added to task with reactivity", { taskId: task.id, messageCount: messageIds.length });
|
||||||
|
} else {
|
||||||
|
console.log("[task-actions] message ID already in task", { taskId: task.id });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[task-actions] task not found in session", { targetTaskId, sessionId, availableTaskCount: targetSession.tasks.length });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceTaskMessageId(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
oldMessageId: string,
|
||||||
|
newMessageId: string,
|
||||||
|
): void {
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
let targetSessionId = sessionId
|
||||||
|
|
||||||
|
if (session.parentId && !session.tasks) {
|
||||||
|
targetSessionId = session.parentId
|
||||||
|
}
|
||||||
|
|
||||||
|
withSession(instanceId, targetSessionId, (targetSession) => {
|
||||||
|
if (!targetSession.tasks) return
|
||||||
|
|
||||||
|
const taskIndex = targetSession.tasks.findIndex((t) =>
|
||||||
|
t.messageIds?.includes(oldMessageId) || t.taskSessionId === sessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (taskIndex !== -1) {
|
||||||
|
const task = targetSession.tasks[taskIndex]
|
||||||
|
const messageIds = [...(task.messageIds || [])]
|
||||||
|
const index = messageIds.indexOf(oldMessageId)
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
if (index !== -1) {
|
||||||
|
messageIds[index] = newMessageId
|
||||||
|
changed = true
|
||||||
|
} else if (task.taskSessionId === sessionId && !messageIds.includes(newMessageId)) {
|
||||||
|
messageIds.push(newMessageId)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
const updatedTask = { ...task, messageIds }
|
||||||
|
const updatedTasks = [...targetSession.tasks]
|
||||||
|
updatedTasks[taskIndex] = updatedTask
|
||||||
|
targetSession.tasks = updatedTasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTaskStatus(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string,
|
||||||
|
status: TaskStatus,
|
||||||
|
): void {
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
if (!session.tasks) return
|
||||||
|
session.tasks = session.tasks.map((t) => (t.id === taskId ? { ...t, status } : t))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTask(instanceId: string, sessionId: string, taskId: string): void {
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
if (!session.tasks) return
|
||||||
|
session.tasks = session.tasks.filter((t) => t.id !== taskId)
|
||||||
|
if (session.activeTaskId === taskId) {
|
||||||
|
session.activeTaskId = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
62
packages/ui/src/styles/antigravity.css
Normal file
62
packages/ui/src/styles/antigravity.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
|
||||||
|
/* Antigravity Glass Effect */
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
transform: translateY(-4px) scale(1.01);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar for Antigravity Theme */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Antigravity Glows */
|
||||||
|
.glow-blue {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
filter: blur(80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-purple {
|
||||||
|
background: rgba(147, 51, 234, 0.05);
|
||||||
|
filter: blur(100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HUD elements */
|
||||||
|
.hud-panel {
|
||||||
|
@apply glass rounded-2xl border-white/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MultiX Branding */
|
||||||
|
.multix-badge {
|
||||||
|
@apply flex items-center bg-blue-500/10 border border-blue-500/20 rounded-md px-2 py-0.5 shadow-[0_0_15px_rgba(59,130,246,0.1)];
|
||||||
|
}
|
||||||
@@ -1,68 +1,43 @@
|
|||||||
/* Prompt input & attachment styles */
|
/* Prompt input & attachment styles */
|
||||||
.prompt-input-container {
|
.prompt-input-container {
|
||||||
@apply flex flex-col border-t;
|
@apply flex flex-col relative mx-auto w-full max-w-4xl;
|
||||||
border-color: var(--border-base);
|
padding: 1rem 1.5rem 1.5rem;
|
||||||
background-color: var(--surface-base);
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-wrapper {
|
.prompt-input-wrapper {
|
||||||
@apply grid items-stretch;
|
@apply relative flex flex-col glass rounded-2xl border border-white/10 shadow-2xl transition-all;
|
||||||
grid-template-columns: minmax(0, 1fr) 64px;
|
background-color: rgba(10, 10, 10, 0.7);
|
||||||
gap: 0;
|
padding: 0.25rem;
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-actions {
|
.prompt-input-wrapper:focus-within {
|
||||||
@apply flex flex-col items-center justify-between;
|
@apply border-blue-500/40 shadow-blue-500/10;
|
||||||
align-self: stretch;
|
background-color: rgba(10, 10, 10, 0.85);
|
||||||
height: 100%;
|
transform: translateY(-1px);
|
||||||
padding: 0.5rem 0.25rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-field-container {
|
.prompt-input-field-container {
|
||||||
position: relative;
|
@apply flex flex-1 min-h-[40px] relative;
|
||||||
width: 100%;
|
|
||||||
min-height: 56px;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-field {
|
.prompt-input {
|
||||||
position: absolute;
|
@apply w-full pl-10 pr-24 py-3 bg-transparent border-none text-[13px] resize-none outline-none text-zinc-100;
|
||||||
inset: 0;
|
font-family: inherit;
|
||||||
width: 100%;
|
line-height: 1.6;
|
||||||
height: 100%;
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input {
|
.prompt-input-actions {
|
||||||
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
|
@apply absolute right-2 bottom-2 flex items-center space-x-1.5 z-20;
|
||||||
font-family: inherit;
|
}
|
||||||
background-color: var(--surface-base);
|
|
||||||
color: inherit;
|
|
||||||
border-color: var(--border-base);
|
|
||||||
line-height: var(--line-height-normal);
|
|
||||||
border-radius: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.send-button, .stop-button {
|
||||||
|
@apply w-8 h-8 rounded-xl border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0 shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-input-overlay {
|
.prompt-input-overlay {
|
||||||
position: absolute;
|
@apply absolute bottom-3 left-10 right-24 flex flex-wrap gap-2 text-[10px] text-zinc-500 pointer-events-none z-10 opacity-60;
|
||||||
bottom: 1rem;
|
|
||||||
left: 0.75rem;
|
|
||||||
right: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: var(--text-muted);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-overlay.shell-mode {
|
.prompt-input-overlay.shell-mode {
|
||||||
@@ -72,7 +47,7 @@
|
|||||||
.prompt-history-top,
|
.prompt-history-top,
|
||||||
.prompt-history-bottom {
|
.prompt-history-bottom {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.35rem;
|
left: 0.35rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -88,21 +63,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prompt-history-button {
|
.prompt-history-button {
|
||||||
@apply w-9 h-9 flex items-center justify-center rounded-md;
|
@apply w-7 h-7 flex items-center justify-center rounded-lg;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background-color: rgba(15, 23, 42, 0.04);
|
background-color: transparent;
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
transition: all 0.2s ease;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-history-button:hover:not(:disabled) {
|
.prompt-history-button:hover:not(:disabled) {
|
||||||
background-color: var(--surface-secondary);
|
@apply bg-white/5 text-white scale-105;
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-history-button:disabled {
|
.prompt-history-button svg {
|
||||||
opacity: 0.4;
|
@apply w-4 h-4;
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-overlay-text {
|
.prompt-overlay-text {
|
||||||
@@ -148,58 +121,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stop-button {
|
.stop-button {
|
||||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
background-color: rgba(239, 68, 68, 0.2);
|
||||||
background-color: rgba(239, 68, 68, 0.85);
|
color: #ef4444;
|
||||||
color: var(--text-inverted);
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-button:hover:not(:disabled) {
|
.stop-button:hover:not(:disabled) {
|
||||||
background-color: rgba(239, 68, 68, 0.9);
|
background-color: rgba(239, 68, 68, 0.3);
|
||||||
@apply opacity-95 scale-105;
|
@apply scale-105;
|
||||||
}
|
|
||||||
|
|
||||||
.stop-button:active:not(:disabled) {
|
|
||||||
background-color: rgba(239, 68, 68, 1);
|
|
||||||
@apply scale-95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-button:disabled {
|
|
||||||
@apply opacity-60 cursor-not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-icon {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-button {
|
.send-button {
|
||||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
background-color: rgba(59, 130, 246, 0.2);
|
||||||
background-color: var(--accent-primary);
|
color: #3b82f6;
|
||||||
color: var(--text-inverted);
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
}
|
|
||||||
|
|
||||||
.send-button.shell-mode {
|
|
||||||
background-color: var(--status-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-button.shell-mode:hover:not(:disabled) {
|
|
||||||
filter: brightness(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-button.shell-mode:active:not(:disabled) {
|
|
||||||
filter: brightness(0.95);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-button:hover:not(:disabled) {
|
.send-button:hover:not(:disabled) {
|
||||||
@apply opacity-90 scale-105;
|
background-color: rgba(59, 130, 246, 0.3);
|
||||||
|
@apply scale-105;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-button:active:not(:disabled) {
|
.send-button.shell-mode {
|
||||||
@apply scale-95;
|
background-color: rgba(34, 197, 94, 0.2);
|
||||||
}
|
color: #22c55e;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
.send-button:disabled {
|
|
||||||
@apply opacity-40 cursor-not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-icon {
|
.send-icon {
|
||||||
|
|||||||
120
packages/ui/src/styles/tabs.css
Normal file
120
packages/ui/src/styles/tabs.css
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/* Tab styles for advanced settings modal */
|
||||||
|
.tab-trigger {
|
||||||
|
@apply px-4 py-2 text-sm font-medium border-b-2 border-transparent hover:border-gray-300
|
||||||
|
data-[state=active]:border-blue-500 data-[state=active]:text-blue-600
|
||||||
|
dark:data-[state=active]:text-blue-400 dark:hover:border-gray-600
|
||||||
|
transition-colors cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional styles for Ollama and Qwen settings */
|
||||||
|
.integration-header {
|
||||||
|
@apply flex items-center gap-2 mb-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-icon {
|
||||||
|
@apply w-6 h-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
@apply text-sm font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected {
|
||||||
|
@apply text-green-600 dark:text-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failed {
|
||||||
|
@apply text-red-600 dark:text-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing {
|
||||||
|
@apply text-blue-600 dark:text-blue-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-grid {
|
||||||
|
@apply grid grid-cols-1 gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item {
|
||||||
|
@apply p-3 border border-gray-200 rounded-md bg-gray-50 dark:border-gray-700 dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
@apply text-sm font-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status {
|
||||||
|
@apply text-center py-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-icon {
|
||||||
|
@apply w-12 h-12 mx-auto text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile {
|
||||||
|
@apply flex items-start justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
@apply flex items-center gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
@apply w-12 h-12 bg-green-100 dark:bg-green-800 rounded-full flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
@apply space-y-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
@apply font-semibold text-green-900 dark:text-green-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
@apply text-sm text-green-700 dark:text-green-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-tier {
|
||||||
|
@apply text-xs px-2 py-1 bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-limits {
|
||||||
|
@apply text-xs text-green-600 dark:text-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
@apply grid grid-cols-1 gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
@apply p-3 border border-gray-200 dark:border-gray-700 rounded-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-title {
|
||||||
|
@apply font-medium text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-description {
|
||||||
|
@apply text-xs text-gray-600 dark:text-gray-400 mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-tips {
|
||||||
|
@apply bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-tips h3 {
|
||||||
|
@apply font-semibold text-blue-900 dark:text-blue-100 mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-tips ul {
|
||||||
|
@apply text-sm text-blue-800 dark:text-blue-200 space-y-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-tips code {
|
||||||
|
@apply bg-blue-100 dark:bg-blue-800 px-1 rounded;
|
||||||
|
}
|
||||||
@@ -15,6 +15,17 @@ export type {
|
|||||||
|
|
||||||
export type SessionStatus = "idle" | "working" | "compacting"
|
export type SessionStatus = "idle" | "working" | "compacting"
|
||||||
|
|
||||||
|
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
|
||||||
|
taskSessionId?: string // Backend session ID for this task
|
||||||
|
}
|
||||||
|
|
||||||
// Our client-specific Session interface extending SDK Session
|
// Our client-specific Session interface extending SDK Session
|
||||||
export interface Session
|
export interface Session
|
||||||
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
|
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
|
||||||
@@ -27,6 +38,8 @@ export interface Session
|
|||||||
}
|
}
|
||||||
version: string // Include version from SDK Session
|
version: string // Include version from SDK Session
|
||||||
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
||||||
|
tasks?: Task[] // Multi-task chat support
|
||||||
|
activeTaskId?: string // Track the currently active task for message isolation
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adapter function to convert SDK Session to client Session
|
// Adapter function to convert SDK Session to client Session
|
||||||
|
|||||||
Reference in New Issue
Block a user