Backup before continuing from Codex 5.2 session - User storage, compaction suggestions, streaming improvements

This commit is contained in:
Gemini AI
2025-12-24 21:27:05 +04:00
Unverified
parent f9748391a9
commit e8c38b0add
93 changed files with 10615 additions and 2037 deletions

View File

@@ -1,5 +1,17 @@
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import path from "path"
import type { CliProcessManager, CliStatus } from "./process-manager"
import {
listUsers,
createUser,
updateUser,
deleteUser,
verifyPassword,
setActiveUser,
createGuestUser,
getActiveUser,
getUserDataRoot,
} from "./user-store"
interface DialogOpenRequest {
mode: "directory" | "file"
@@ -40,6 +52,41 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return cliManager.start({ dev: devMode })
})
ipcMain.handle("users:list", async () => listUsers())
ipcMain.handle("users:active", async () => getActiveUser())
ipcMain.handle("users:create", async (_, payload: { name: string; password: string }) => {
const user = createUser(payload.name, payload.password)
return user
})
ipcMain.handle("users:update", async (_, payload: { id: string; name?: string; password?: string }) => {
const user = updateUser(payload.id, { name: payload.name, password: payload.password })
return user
})
ipcMain.handle("users:delete", async (_, payload: { id: string }) => {
deleteUser(payload.id)
return { success: true }
})
ipcMain.handle("users:createGuest", async () => {
const user = createGuestUser()
return user
})
ipcMain.handle("users:login", async (_, payload: { id: string; password?: string }) => {
const ok = verifyPassword(payload.id, payload.password ?? "")
if (!ok) {
return { success: false }
}
const user = setActiveUser(payload.id)
const root = getUserDataRoot(user.id)
cliManager.setUserEnv({
CODENOMAD_USER_DIR: root,
CLI_CONFIG: path.join(root, "config.json"),
})
await cliManager.stop()
const devMode = process.env.NODE_ENV === "development"
await cliManager.start({ dev: devMode })
return { success: true, user }
})
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc"
import { CliProcessManager } from "./process-manager"
import { ensureDefaultUsers, getActiveUser, getUserDataRoot, clearGuestUsers } from "./user-store"
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename)
@@ -225,6 +226,24 @@ function getPreloadPath() {
return join(mainDirname, "../preload/index.js")
}
function applyUserEnvToCli() {
const active = getActiveUser()
if (!active) {
const fallback = ensureDefaultUsers()
const fallbackRoot = getUserDataRoot(fallback.id)
cliManager.setUserEnv({
CODENOMAD_USER_DIR: fallbackRoot,
CLI_CONFIG: join(fallbackRoot, "config.json"),
})
return
}
const root = getUserDataRoot(active.id)
cliManager.setUserEnv({
CODENOMAD_USER_DIR: root,
CLI_CONFIG: join(root, "config.json"),
})
}
function destroyPreloadingView(target?: BrowserView | null) {
const view = target ?? preloadingView
if (!view) {
@@ -274,7 +293,7 @@ function createWindow() {
currentCliUrl = null
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development" && process.env.NOMADARCH_OPEN_DEVTOOLS === "true") {
mainWindow.webContents.openDevTools({ mode: "detach" })
}
@@ -452,6 +471,8 @@ if (isMac) {
}
app.whenReady().then(() => {
ensureDefaultUsers()
applyUserEnvToCli()
startCli()
if (isMac) {
@@ -480,6 +501,7 @@ app.whenReady().then(() => {
app.on("before-quit", async (event) => {
event.preventDefault()
await cliManager.stop().catch(() => { })
clearGuestUsers()
app.exit(0)
})

View File

@@ -79,6 +79,11 @@ export class CliProcessManager extends EventEmitter {
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
private userEnv: Record<string, string> = {}
setUserEnv(env: Record<string, string>) {
this.userEnv = { ...env }
}
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
@@ -100,6 +105,7 @@ export class CliProcessManager extends EventEmitter {
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
Object.assign(env, this.userEnv)
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
@@ -274,7 +280,8 @@ export class CliProcessManager extends EventEmitter {
const args = ["serve", "--host", host, "--port", "0"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
const uiPort = process.env.VITE_PORT || "3000"
args.push("--ui-dev-server", `http://localhost:${uiPort}`, "--log-level", "debug")
}
return args

View File

@@ -0,0 +1,267 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, cpSync } from "fs"
import os from "os"
import path from "path"
import crypto from "crypto"
interface UserRecord {
id: string
name: string
salt?: string
passwordHash?: string
isGuest?: boolean
createdAt: string
updatedAt: string
}
interface UserStoreState {
users: UserRecord[]
activeUserId?: string
}
const CONFIG_ROOT = path.join(os.homedir(), ".config", "codenomad")
const USERS_FILE = path.join(CONFIG_ROOT, "users.json")
const USERS_ROOT = path.join(CONFIG_ROOT, "users")
const LEGACY_ROOT = CONFIG_ROOT
const LEGACY_INTEGRATIONS_ROOT = path.join(os.homedir(), ".nomadarch")
function nowIso() {
return new Date().toISOString()
}
function sanitizeId(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9-_]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
}
function hashPassword(password: string, salt: string) {
return crypto.pbkdf2Sync(password, salt, 120000, 32, "sha256").toString("base64")
}
function generateSalt() {
return crypto.randomBytes(16).toString("base64")
}
function ensureDir(dir: string) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function readStore(): UserStoreState {
try {
if (!existsSync(USERS_FILE)) {
return { users: [] }
}
const content = readFileSync(USERS_FILE, "utf-8")
const parsed = JSON.parse(content) as UserStoreState
return {
users: Array.isArray(parsed.users) ? parsed.users : [],
activeUserId: parsed.activeUserId,
}
} catch {
return { users: [] }
}
}
function writeStore(state: UserStoreState) {
ensureDir(CONFIG_ROOT)
ensureDir(USERS_ROOT)
writeFileSync(USERS_FILE, JSON.stringify(state, null, 2), "utf-8")
}
function ensureUniqueId(base: string, existing: Set<string>) {
let candidate = sanitizeId(base) || "user"
let index = 1
while (existing.has(candidate)) {
candidate = `${candidate}-${index}`
index += 1
}
return candidate
}
function getUserDir(userId: string) {
return path.join(USERS_ROOT, userId)
}
function migrateLegacyData(targetDir: string) {
const legacyConfig = path.join(LEGACY_ROOT, "config.json")
const legacyInstances = path.join(LEGACY_ROOT, "instances")
const legacyWorkspaces = path.join(LEGACY_ROOT, "opencode-workspaces")
ensureDir(targetDir)
if (existsSync(legacyConfig)) {
cpSync(legacyConfig, path.join(targetDir, "config.json"), { force: true })
}
if (existsSync(legacyInstances)) {
cpSync(legacyInstances, path.join(targetDir, "instances"), { recursive: true, force: true })
}
if (existsSync(legacyWorkspaces)) {
cpSync(legacyWorkspaces, path.join(targetDir, "opencode-workspaces"), { recursive: true, force: true })
}
if (existsSync(LEGACY_INTEGRATIONS_ROOT)) {
cpSync(LEGACY_INTEGRATIONS_ROOT, path.join(targetDir, "integrations"), { recursive: true, force: true })
}
}
export function ensureDefaultUsers(): UserRecord {
const store = readStore()
if (store.users.length > 0) {
const active = store.users.find((u) => u.id === store.activeUserId) ?? store.users[0]
if (!store.activeUserId) {
store.activeUserId = active.id
writeStore(store)
}
return active
}
const existingIds = new Set<string>()
const userId = ensureUniqueId("roman", existingIds)
const salt = generateSalt()
const passwordHash = hashPassword("q1w2e3r4", salt)
const record: UserRecord = {
id: userId,
name: "roman",
salt,
passwordHash,
createdAt: nowIso(),
updatedAt: nowIso(),
}
store.users.push(record)
store.activeUserId = record.id
writeStore(store)
const userDir = getUserDir(record.id)
migrateLegacyData(userDir)
return record
}
export function listUsers(): UserRecord[] {
return readStore().users
}
export function getActiveUser(): UserRecord | null {
const store = readStore()
if (!store.activeUserId) return null
return store.users.find((user) => user.id === store.activeUserId) ?? null
}
export function setActiveUser(userId: string) {
const store = readStore()
const user = store.users.find((u) => u.id === userId)
if (!user) {
throw new Error("User not found")
}
store.activeUserId = userId
writeStore(store)
return user
}
export function createUser(name: string, password: string) {
const store = readStore()
const existingIds = new Set(store.users.map((u) => u.id))
const id = ensureUniqueId(name, existingIds)
const salt = generateSalt()
const passwordHash = hashPassword(password, salt)
const record: UserRecord = {
id,
name,
salt,
passwordHash,
createdAt: nowIso(),
updatedAt: nowIso(),
}
store.users.push(record)
writeStore(store)
ensureDir(getUserDir(id))
return record
}
export function createGuestUser() {
const store = readStore()
const existingIds = new Set(store.users.map((u) => u.id))
const id = ensureUniqueId(`guest-${crypto.randomUUID().slice(0, 8)}`, existingIds)
const record: UserRecord = {
id,
name: "Guest",
isGuest: true,
createdAt: nowIso(),
updatedAt: nowIso(),
}
store.users.push(record)
store.activeUserId = id
writeStore(store)
ensureDir(getUserDir(id))
return record
}
export function updateUser(userId: string, updates: { name?: string; password?: string }) {
const store = readStore()
const target = store.users.find((u) => u.id === userId)
if (!target) {
throw new Error("User not found")
}
if (updates.name) {
target.name = updates.name
}
if (updates.password && !target.isGuest) {
const salt = generateSalt()
target.salt = salt
target.passwordHash = hashPassword(updates.password, salt)
}
target.updatedAt = nowIso()
writeStore(store)
return target
}
export function deleteUser(userId: string) {
const store = readStore()
const target = store.users.find((u) => u.id === userId)
if (!target) return
store.users = store.users.filter((u) => u.id !== userId)
if (store.activeUserId === userId) {
store.activeUserId = store.users[0]?.id
}
writeStore(store)
const dir = getUserDir(userId)
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true })
}
}
export function verifyPassword(userId: string, password: string): boolean {
const store = readStore()
const user = store.users.find((u) => u.id === userId)
if (!user) return false
if (user.isGuest) return true
if (!user.salt || !user.passwordHash) return false
return hashPassword(password, user.salt) === user.passwordHash
}
export function getUserDataRoot(userId: string) {
return getUserDir(userId)
}
export function clearGuestUsers() {
const store = readStore()
const guests = store.users.filter((u) => u.isGuest)
if (guests.length === 0) return
store.users = store.users.filter((u) => !u.isGuest)
if (store.activeUserId && guests.some((u) => u.id === store.activeUserId)) {
store.activeUserId = store.users[0]?.id
}
writeStore(store)
for (const guest of guests) {
const dir = getUserDir(guest.id)
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true })
}
}
}

View File

@@ -12,6 +12,13 @@ const electronAPI = {
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
listUsers: () => ipcRenderer.invoke("users:list"),
getActiveUser: () => ipcRenderer.invoke("users:active"),
createUser: (payload) => ipcRenderer.invoke("users:create", payload),
updateUser: (payload) => ipcRenderer.invoke("users:update", payload),
deleteUser: (payload) => ipcRenderer.invoke("users:delete", payload),
createGuest: () => ipcRenderer.invoke("users:createGuest"),
loginUser: (payload) => ipcRenderer.invoke("users:login", payload),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -15,7 +15,7 @@
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": {
"dev": "electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"dev:electron": "cross-env NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview",
@@ -40,6 +40,7 @@
"devDependencies": {
"7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",

View File

@@ -79,6 +79,37 @@ export type WorkspaceCreateResponse = WorkspaceDescriptor
export type WorkspaceListResponse = WorkspaceDescriptor[]
export type WorkspaceDetailResponse = WorkspaceDescriptor
export interface WorkspaceExportRequest {
destination: string
includeConfig?: boolean
}
export interface WorkspaceExportResponse {
destination: string
}
export interface WorkspaceImportRequest {
source: string
destination: string
includeConfig?: boolean
}
export type WorkspaceImportResponse = WorkspaceDescriptor
export interface WorkspaceMcpConfig {
mcpServers?: Record<string, unknown>
}
export interface WorkspaceMcpConfigResponse {
path: string
exists: boolean
config: WorkspaceMcpConfig
}
export interface WorkspaceMcpConfigRequest {
config: WorkspaceMcpConfig
}
export interface WorkspaceDeleteResponse {
id: string
status: WorkspaceStatus
@@ -159,6 +190,11 @@ export interface InstanceData {
agentModelSelections: AgentModelSelection
sessionTasks?: SessionTasks // Multi-task chat support: tasks per session
sessionSkills?: Record<string, SkillSelection[]> // Selected skills per session
customAgents?: Array<{
name: string
description?: string
prompt: string
}>
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
@@ -269,6 +305,10 @@ export interface ServerMeta {
latestRelease?: LatestReleaseInfo
}
export interface PortAvailabilityResponse {
port: number
}
export type {
Preferences,
ModelPreference,

View File

@@ -16,6 +16,7 @@ import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { getUserConfigPath } from "./user-data"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
@@ -41,7 +42,7 @@ interface CliOptions {
const DEFAULT_PORT = 9898
const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
const DEFAULT_CONFIG_PATH = getUserConfigPath()
function parseCliOptions(argv: string[]): CliOptions {
const program = new Command()

View File

@@ -1,11 +1,5 @@
/**
* 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"),
@@ -14,31 +8,56 @@ export const OllamaCloudConfigSchema = z.object({
export type OllamaCloudConfig = z.infer<typeof OllamaCloudConfigSchema>
// Model information schema
// Schema is flexible since Ollama Cloud may return different fields than local Ollama
export const OllamaModelSchema = z.object({
name: z.string(),
size: z.string(),
digest: z.string(),
modified_at: z.string(),
created_at: z.string()
model: z.string().optional(), // Some APIs return model instead of name
size: z.union([z.string(), z.number()]).optional(),
digest: z.string().optional(),
modified_at: z.string().optional(),
created_at: z.string().optional(),
details: z.any().optional() // Model details like family, parameter_size, etc.
})
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()
images: z.array(z.string()).optional(),
tool_calls: z.array(z.any()).optional(),
thinking: z.string().optional()
})
export type ChatMessage = z.infer<typeof ChatMessageSchema>
// Chat request/response schemas
export const ToolCallSchema = z.object({
name: z.string(),
arguments: z.record(z.any())
})
export type ToolCall = z.infer<typeof ToolCallSchema>
export const ToolDefinitionSchema = z.object({
name: z.string(),
description: z.string(),
parameters: z.object({
type: z.enum(["object", "string", "number", "boolean", "array"]),
properties: z.record(z.any()),
required: z.array(z.string()).optional()
})
})
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>
export const ChatRequestSchema = z.object({
model: z.string(),
messages: z.array(ChatMessageSchema),
stream: z.boolean().default(false),
think: z.union([z.boolean(), z.enum(["low", "medium", "high"])]).optional(),
format: z.union([z.literal("json"), z.any()]).optional(),
tools: z.array(ToolDefinitionSchema).optional(),
web_search: z.boolean().optional(),
options: z.object({
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional()
@@ -48,7 +67,10 @@ export const ChatRequestSchema = z.object({
export const ChatResponseSchema = z.object({
model: z.string(),
created_at: z.string(),
message: ChatMessageSchema,
message: ChatMessageSchema.extend({
thinking: z.string().optional(),
tool_calls: z.array(z.any()).optional()
}),
done: z.boolean().optional(),
total_duration: z.number().optional(),
load_duration: z.number().optional(),
@@ -61,23 +83,32 @@ export const ChatResponseSchema = z.object({
export type ChatRequest = z.infer<typeof ChatRequestSchema>
export type ChatResponse = z.infer<typeof ChatResponseSchema>
export const EmbeddingRequestSchema = z.object({
model: z.string(),
input: z.union([z.string(), z.array(z.string())])
})
export type EmbeddingRequest = z.infer<typeof EmbeddingRequestSchema>
export const EmbeddingResponseSchema = z.object({
model: z.string(),
embeddings: z.array(z.array(z.number()))
})
export type EmbeddingResponse = z.infer<typeof EmbeddingResponseSchema>
export class OllamaCloudClient {
private config: OllamaCloudConfig
private baseUrl: string
constructor(config: OllamaCloudConfig) {
this.config = config
this.baseUrl = config.endpoint.replace(/\/$/, "") // Remove trailing slash
this.baseUrl = config.endpoint.replace(/\/$/, "")
}
/**
* Test connection to Ollama Cloud API
*/
async testConnection(): Promise<boolean> {
try {
const response = await this.makeRequest("/api/tags", {
method: "GET"
})
const response = await this.makeRequest("/tags", { method: "GET" })
return response.ok
} catch (error) {
console.error("Ollama Cloud connection test failed:", error)
@@ -85,30 +116,85 @@ export class OllamaCloudClient {
}
}
/**
* List available models
*/
async listModels(): Promise<OllamaModel[]> {
try {
const response = await this.makeRequest("/api/tags", {
method: "GET"
const headers: Record<string, string> = {}
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
const cloudResponse = await fetch(`${this.baseUrl}/v1/models`, {
method: "GET",
headers
})
if (cloudResponse.ok) {
const data = await cloudResponse.json()
const modelsArray = Array.isArray(data?.data) ? data.data : []
const parsedModels = modelsArray
.map((model: any) => ({
name: model.id || model.name || model.model,
model: model.id || model.model || model.name,
}))
.filter((model: any) => model.name)
if (parsedModels.length > 0) {
return parsedModels
}
}
const response = await this.makeRequest("/tags", { method: "GET" })
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`)
const errorText = await response.text().catch(() => "Unknown error")
console.error(`[OllamaCloud] Failed to fetch models: ${response.status} ${response.statusText}`, errorText)
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText} - ${errorText}`)
}
const data = await response.json()
return z.array(OllamaModelSchema).parse(data.models || [])
console.log("[OllamaCloud] Models response:", JSON.stringify(data).substring(0, 500))
// Handle different response formats flexibly
const modelsArray = Array.isArray(data.models) ? data.models :
Array.isArray(data) ? data : []
// Parse with flexible schema, don't throw on validation failure
// Only include cloud-compatible models (ending in -cloud or known cloud models)
const parsedModels: OllamaModel[] = []
for (const model of modelsArray) {
try {
const modelName = model.name || model.model || ""
// Filter to only cloud-compatible models
const isCloudModel = modelName.endsWith("-cloud") ||
modelName.includes(":cloud") ||
modelName.startsWith("gpt-oss") ||
modelName.startsWith("qwen3-coder") ||
modelName.startsWith("deepseek-v3")
if (modelName && isCloudModel) {
parsedModels.push({
name: modelName,
model: model.model || modelName,
size: model.size,
digest: model.digest,
modified_at: model.modified_at,
created_at: model.created_at,
details: model.details
})
}
} catch (parseError) {
console.warn("[OllamaCloud] Skipping model due to parse error:", model, parseError)
}
}
console.log(`[OllamaCloud] Parsed ${parsedModels.length} cloud-compatible models`)
return parsedModels
} 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")
@@ -118,20 +204,20 @@ export class OllamaCloudClient {
"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`, {
const response = await this.makeRequest("/chat", {
method: "POST",
headers,
body: JSON.stringify(request)
})
if (!response.ok) {
throw new Error(`Chat request failed: ${response.statusText}`)
const errorText = await response.text()
throw new Error(`Chat request failed: ${response.statusText} - ${errorText}`)
}
if (request.stream) {
@@ -146,9 +232,85 @@ export class OllamaCloudClient {
}
}
/**
* Pull a model (for cloud models, this just makes them available)
*/
async chatWithThinking(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
const requestWithThinking = {
...request,
think: true
}
return this.chat(requestWithThinking)
}
async chatWithStructuredOutput(request: ChatRequest, schema: any): Promise<AsyncIterable<ChatResponse>> {
const requestWithFormat = {
...request,
format: schema
}
return this.chat(requestWithFormat)
}
async chatWithVision(request: ChatRequest, images: string[]): Promise<AsyncIterable<ChatResponse>> {
if (!request.messages.length) {
throw new Error("At least one message is required")
}
const messagesWithImages = [...request.messages]
const lastUserMessage = messagesWithImages.slice().reverse().find(m => m.role === "user")
if (lastUserMessage) {
lastUserMessage.images = images
}
return this.chat({ ...request, messages: messagesWithImages })
}
async chatWithTools(request: ChatRequest, tools: ToolDefinition[]): Promise<AsyncIterable<ChatResponse>> {
const requestWithTools = {
...request,
tools
}
return this.chat(requestWithTools)
}
async chatWithWebSearch(request: ChatRequest): Promise<AsyncIterable<ChatResponse>> {
const requestWithWebSearch = {
...request,
web_search: true
}
return this.chat(requestWithWebSearch)
}
async generateEmbeddings(request: EmbeddingRequest): Promise<EmbeddingResponse> {
if (!this.config.apiKey) {
throw new Error("Ollama Cloud API key is required")
}
const headers: Record<string, string> = {
"Content-Type": "application/json"
}
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
try {
const response = await this.makeRequest("/embed", {
method: "POST",
headers,
body: JSON.stringify(request)
})
if (!response.ok) {
throw new Error(`Embeddings request failed: ${response.statusText}`)
}
const data = await response.json()
return EmbeddingResponseSchema.parse(data)
} catch (error) {
console.error("Ollama Cloud embeddings request failed:", error)
throw error
}
}
async pullModel(modelName: string): Promise<void> {
const headers: Record<string, string> = {
"Content-Type": "application/json"
@@ -158,7 +320,7 @@ export class OllamaCloudClient {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
const response = await fetch(`${this.baseUrl}/api/pull`, {
const response = await this.makeRequest("/pull", {
method: "POST",
headers,
body: JSON.stringify({ name: modelName })
@@ -169,9 +331,6 @@ export class OllamaCloudClient {
}
}
/**
* Parse streaming response
*/
private async *parseStreamingResponse(response: Response): AsyncIterable<ChatResponse> {
if (!response.body) {
throw new Error("Response body is missing")
@@ -186,18 +345,17 @@ export class OllamaCloudClient {
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)
}
}
@@ -207,61 +365,72 @@ export class OllamaCloudClient {
}
}
/**
* 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}`
// Ensure endpoint starts with /api
const apiEndpoint = endpoint.startsWith('/api') ? endpoint : `/api${endpoint}`
const url = `${this.baseUrl}${apiEndpoint}`
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}`
}
console.log(`[OllamaCloud] Making request to: ${url}`)
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)
}
async getThinkingCapableModels(): Promise<string[]> {
const allModels = await this.listModels()
const thinkingModelPatterns = ["qwen3", "deepseek-r1", "gpt-oss", "deepseek-v3.1"]
return allModels
.map(m => m.name)
.filter(name => thinkingModelPatterns.some(pattern => name.toLowerCase().includes(pattern)))
}
async getVisionCapableModels(): Promise<string[]> {
const allModels = await this.listModels()
const visionModelPatterns = ["gemma3", "llama3.2-vision", "llava", "bakllava", "minicpm-v"]
return allModels
.map(m => m.name)
.filter(name => visionModelPatterns.some(pattern => name.toLowerCase().includes(pattern)))
}
async getEmbeddingModels(): Promise<string[]> {
const allModels = await this.listModels()
const embeddingModelPatterns = ["embeddinggemma", "qwen3-embedding", "all-minilm", "nomic-embed", "mxbai-embed"]
return allModels
.map(m => m.name)
.filter(name => embeddingModelPatterns.some(pattern => name.toLowerCase().includes(pattern)))
}
}
// Default cloud models based on Ollama documentation
export const DEFAULT_CLOUD_MODELS = [
"gpt-oss:120b-cloud",
"llama3.1:70b-cloud",
@@ -270,4 +439,32 @@ export const DEFAULT_CLOUD_MODELS = [
"qwen2.5:7b-cloud"
] as const
export type CloudModelName = typeof DEFAULT_CLOUD_MODELS[number]
export type CloudModelName = typeof DEFAULT_CLOUD_MODELS[number]
export const THINKING_MODELS = [
"qwen3",
"deepseek-r1",
"deepseek-v3.1",
"gpt-oss:120b-cloud"
] as const
export type ThinkingModelName = typeof THINKING_MODELS[number]
export const VISION_MODELS = [
"gemma3",
"llava",
"bakllava",
"minicpm-v"
] as const
export type VisionModelName = typeof VISION_MODELS[number]
export const EMBEDDING_MODELS = [
"embeddinggemma",
"qwen3-embedding",
"all-minilm",
"nomic-embed-text",
"mxbai-embed-large"
] as const
export type EmbeddingModelName = typeof EMBEDDING_MODELS[number]

View File

@@ -11,8 +11,8 @@ import { z } from "zod"
// Configuration schema for OpenCode Zen
export const OpenCodeZenConfigSchema = z.object({
enabled: z.boolean().default(true), // Free models enabled by default
endpoint: z.string().default("https://api.opencode.ai/v1"),
apiKey: z.string().default("public") // "public" key for free models
endpoint: z.string().default("https://opencode.ai/zen/v1"),
apiKey: z.string().optional()
})
export type OpenCodeZenConfig = z.infer<typeof OpenCodeZenConfigSchema>
@@ -104,10 +104,10 @@ export const FREE_ZEN_MODELS: ZenModel[] = [
attachment: false,
temperature: true,
cost: { input: 0, output: 0 },
limit: { context: 128000, output: 16384 }
limit: { context: 200000, output: 128000 }
},
{
id: "grok-code-fast-1",
id: "grok-code",
name: "Grok Code Fast 1",
family: "grok",
reasoning: true,
@@ -115,18 +115,29 @@ export const FREE_ZEN_MODELS: ZenModel[] = [
attachment: false,
temperature: true,
cost: { input: 0, output: 0 },
limit: { context: 256000, output: 10000 }
limit: { context: 256000, output: 256000 }
},
{
id: "minimax-m2.1",
name: "MiniMax M2.1",
family: "minimax",
id: "glm-4.7-free",
name: "GLM-4.7",
family: "glm-free",
reasoning: true,
tool_call: true,
attachment: false,
temperature: true,
cost: { input: 0, output: 0 },
limit: { context: 205000, output: 131072 }
limit: { context: 204800, output: 131072 }
},
{
id: "alpha-doubao-seed-code",
name: "Doubao Seed Code (alpha)",
family: "doubao",
reasoning: true,
tool_call: true,
attachment: false,
temperature: true,
cost: { input: 0, output: 0 },
limit: { context: 256000, output: 32000 }
}
]
@@ -217,13 +228,19 @@ export class OpenCodeZenClient {
* Chat completion (streaming)
*/
async *chatStream(request: ChatRequest): AsyncGenerator<ChatChunk> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "NomadArch/1.0",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "NomadArch"
}
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.config.apiKey}`,
"User-Agent": "NomadArch/1.0"
},
headers,
body: JSON.stringify({
...request,
stream: true
@@ -281,13 +298,19 @@ export class OpenCodeZenClient {
* Chat completion (non-streaming)
*/
async chat(request: ChatRequest): Promise<ChatChunk> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "NomadArch/1.0",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "NomadArch"
}
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.config.apiKey}`,
"User-Agent": "NomadArch/1.0"
},
headers,
body: JSON.stringify({
...request,
stream: false
@@ -306,7 +329,6 @@ export class OpenCodeZenClient {
export function getDefaultZenConfig(): OpenCodeZenConfig {
return {
enabled: true,
endpoint: "https://api.opencode.ai/v1",
apiKey: "public"
endpoint: "https://opencode.ai/zen/v1"
}
}

View File

@@ -1,113 +1,111 @@
/**
* Z.AI API Integration
* Provides access to Z.AI's GLM Coding Plan API (Anthropic-compatible)
* Based on https://docs.z.ai/devpack/tool/claude#step-2-config-glm-coding-plan
*/
import { z } from "zod"
// Configuration schema for Z.AI
export const ZAIConfigSchema = z.object({
apiKey: z.string().optional(),
endpoint: z.string().default("https://api.z.ai/api/anthropic"),
endpoint: z.string().default("https://api.z.ai/api/paas/v4"),
enabled: z.boolean().default(false),
timeout: z.number().default(3000000) // 50 minutes as per docs
timeout: z.number().default(300000)
})
export type ZAIConfig = z.infer<typeof ZAIConfigSchema>
// Message schema (Anthropic-compatible)
export const ZAIMessageSchema = z.object({
role: z.enum(["user", "assistant"]),
role: z.enum(["user", "assistant", "system"]),
content: z.string()
})
export type ZAIMessage = z.infer<typeof ZAIMessageSchema>
// Chat request schema
export const ZAIChatRequestSchema = z.object({
model: z.string().default("claude-sonnet-4-20250514"),
model: z.string().default("glm-4.7"),
messages: z.array(ZAIMessageSchema),
max_tokens: z.number().default(8192),
stream: z.boolean().default(true),
system: z.string().optional()
temperature: z.number().optional(),
thinking: z.object({
type: z.enum(["enabled", "disabled"]).optional()
}).optional()
})
export type ZAIChatRequest = z.infer<typeof ZAIChatRequestSchema>
// Chat response schema
export const ZAIChatResponseSchema = z.object({
id: z.string(),
type: z.string(),
role: z.string(),
content: z.array(z.object({
type: z.string(),
text: z.string().optional()
})),
object: z.string(),
created: z.number(),
model: z.string(),
stop_reason: z.string().nullable().optional(),
stop_sequence: z.string().nullable().optional(),
choices: z.array(z.object({
index: z.number(),
message: z.object({
role: z.string(),
content: z.string().optional(),
reasoning_content: z.string().optional()
}),
finish_reason: z.string()
})),
usage: z.object({
input_tokens: z.number(),
output_tokens: z.number()
}).optional()
prompt_tokens: z.number(),
completion_tokens: z.number(),
total_tokens: z.number()
})
})
export type ZAIChatResponse = z.infer<typeof ZAIChatResponseSchema>
// Stream chunk schema
export const ZAIStreamChunkSchema = z.object({
type: z.string(),
index: z.number().optional(),
delta: z.object({
type: z.string().optional(),
text: z.string().optional()
}).optional(),
message: z.object({
id: z.string(),
type: z.string(),
role: z.string(),
content: z.array(z.any()),
model: z.string()
}).optional(),
content_block: z.object({
type: z.string(),
text: z.string()
}).optional()
id: z.string(),
object: z.string(),
created: z.number(),
model: z.string(),
choices: z.array(z.object({
index: z.number(),
delta: z.object({
role: z.string().optional(),
content: z.string().optional(),
reasoning_content: z.string().optional()
}),
finish_reason: z.string().nullable().optional()
}))
})
export type ZAIStreamChunk = z.infer<typeof ZAIStreamChunkSchema>
export const ZAI_MODELS = [
"glm-4.7",
"glm-4.6",
"glm-4.5",
"glm-4.5-air",
"glm-4.5-flash",
"glm-4.5-long"
] as const
export type ZAIModelName = typeof ZAI_MODELS[number]
export class ZAIClient {
private config: ZAIConfig
private baseUrl: string
constructor(config: ZAIConfig) {
this.config = config
this.baseUrl = config.endpoint.replace(/\/$/, "") // Remove trailing slash
this.baseUrl = config.endpoint.replace(/\/$/, "")
}
/**
* Test connection to Z.AI API
*/
async testConnection(): Promise<boolean> {
if (!this.config.apiKey) {
return false
}
try {
// Make a minimal request to test auth
const response = await fetch(`${this.baseUrl}/v1/messages`, {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
model: "glm-4.7",
max_tokens: 1,
messages: [{ role: "user", content: "test" }]
})
})
// Any response other than auth error means connection works
return response.status !== 401 && response.status !== 403
} catch (error) {
console.error("Z.AI connection test failed:", error)
@@ -115,28 +113,16 @@ export class ZAIClient {
}
}
/**
* List available models
*/
async listModels(): Promise<string[]> {
// Z.AI provides access to Claude models through their proxy
return [
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-opus-20240229",
"claude-3-haiku-20240307"
]
return [...ZAI_MODELS]
}
/**
* Chat completion (streaming)
*/
async *chatStream(request: ZAIChatRequest): AsyncGenerator<ZAIStreamChunk> {
if (!this.config.apiKey) {
throw new Error("Z.AI API key is required")
}
const response = await fetch(`${this.baseUrl}/v1/messages`, {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({
@@ -165,7 +151,7 @@ export class ZAIClient {
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || "" // Keep incomplete line in buffer
buffer = lines.pop() || ""
for (const line of lines) {
if (line.startsWith("data: ")) {
@@ -176,7 +162,6 @@ export class ZAIClient {
const parsed = JSON.parse(data)
yield parsed as ZAIStreamChunk
} catch (e) {
// Skip invalid JSON
}
}
}
@@ -186,15 +171,12 @@ export class ZAIClient {
}
}
/**
* Chat completion (non-streaming)
*/
async chat(request: ZAIChatRequest): Promise<ZAIChatResponse> {
if (!this.config.apiKey) {
throw new Error("Z.AI API key is required")
}
const response = await fetch(`${this.baseUrl}/v1/messages`, {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({
@@ -211,31 +193,14 @@ export class ZAIClient {
return await response.json()
}
/**
* Get request headers
*/
private getHeaders(): Record<string, string> {
return {
"Content-Type": "application/json",
"x-api-key": this.config.apiKey || "",
"anthropic-version": "2023-06-01"
"Authorization": `Bearer ${this.config.apiKey}`
}
}
/**
* Validate API key
*/
static validateApiKey(apiKey: string): boolean {
return typeof apiKey === "string" && apiKey.length > 0
}
}
// Default available models
export const ZAI_MODELS = [
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-opus-20240229",
"claude-3-haiku-20240307"
] as const
export type ZAIModelName = typeof ZAI_MODELS[number]
}

View File

@@ -3,6 +3,7 @@ import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
import { getOpencodeWorkspacesRoot, getUserDataRoot } from "./user-data"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
@@ -12,7 +13,8 @@ const prodTemplateDir = path.resolve(__dirname, "opencode-config")
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild ? devTemplateDir : prodTemplateDir
const userConfigDir = path.join(os.homedir(), ".config", "codenomad", "opencode-config")
const userConfigDir = path.join(getUserDataRoot(), "opencode-config")
const workspaceConfigRoot = getOpencodeWorkspacesRoot()
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {
@@ -28,6 +30,28 @@ export function getOpencodeConfigDir(): string {
return userConfigDir
}
export function ensureWorkspaceOpencodeConfig(workspaceId: string): string {
if (!workspaceId) {
return getOpencodeConfigDir()
}
if (!existsSync(templateDir)) {
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
}
const targetDir = path.join(workspaceConfigRoot, workspaceId)
if (existsSync(targetDir)) {
return targetDir
}
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(templateDir, targetDir, { recursive: true })
return targetDir
}
export function getWorkspaceOpencodeConfigDir(workspaceId: string): string {
return path.join(workspaceConfigRoot, workspaceId)
}
function refreshUserConfig() {
log.debug({ templateDir, userConfigDir }, "Syncing Opencode config template")
rmSync(userConfigDir, { recursive: true, force: true })

View File

@@ -105,7 +105,11 @@ export function createHttpServer(deps: HttpServerDeps) {
},
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerWorkspaceRoutes(app, {
workspaceManager: deps.workspaceManager,
instanceStore: deps.instanceStore,
configStore: deps.configStore,
})
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
@@ -119,7 +123,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerQwenRoutes(app, { logger: deps.logger })
registerZAIRoutes(app, { logger: deps.logger })
registerOpenCodeZenRoutes(app, { logger: deps.logger })
await registerSkillsRoutes(app)
registerSkillsRoutes(app)
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify"
import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types"
import { NetworkAddress, ServerMeta, PortAvailabilityResponse } from "../../api-types"
import { getAvailablePort } from "../../utils/port"
interface RouteDeps {
serverMeta: ServerMeta
@@ -8,6 +9,11 @@ interface RouteDeps {
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
app.get("/api/ports/available", async () => {
const port = await getAvailablePort(3000)
const response: PortAvailabilityResponse = { port }
return response
})
}
function buildMetaResponse(meta: ServerMeta): ServerMeta {

View File

@@ -1,6 +1,18 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { OllamaCloudClient, type OllamaCloudConfig, type ChatRequest } from "../../integrations/ollama-cloud"
import {
OllamaCloudClient,
type OllamaCloudConfig,
type ChatRequest,
type EmbeddingRequest,
type ToolDefinition
} from "../../integrations/ollama-cloud"
import { Logger } from "../../logger"
import fs from "fs"
import path from "path"
import { getUserIntegrationsDir } from "../../user-data"
const CONFIG_DIR = getUserIntegrationsDir()
const CONFIG_FILE = path.join(CONFIG_DIR, "ollama-config.json")
interface OllamaRouteDeps {
logger: Logger
@@ -12,7 +24,6 @@ export async function registerOllamaRoutes(
) {
const logger = deps.logger.child({ component: "ollama-routes" })
// Get Ollama Cloud configuration
app.get('/api/ollama/config', async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -23,15 +34,16 @@ export async function registerOllamaRoutes(
}
})
// Update Ollama Cloud configuration
app.post('/api/ollama/config', {
schema: {
type: 'object',
required: ['enabled'],
properties: {
enabled: { type: 'boolean' },
apiKey: { type: 'string' },
endpoint: { type: 'string' }
body: {
type: 'object',
required: ['enabled'],
properties: {
enabled: { type: 'boolean' },
apiKey: { type: 'string' },
endpoint: { type: 'string' }
}
}
}
}, async (request, reply) => {
@@ -46,7 +58,6 @@ export async function registerOllamaRoutes(
}
})
// Test Ollama Cloud connection
app.post('/api/ollama/test', async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -56,7 +67,7 @@ export async function registerOllamaRoutes(
const client = new OllamaCloudClient(config)
const isConnected = await client.testConnection()
return { connected: isConnected }
} catch (error) {
logger.error({ error }, "Ollama Cloud connection test failed")
@@ -64,7 +75,6 @@ export async function registerOllamaRoutes(
}
})
// List available models
app.get('/api/ollama/models', async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -72,17 +82,19 @@ export async function registerOllamaRoutes(
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
logger.info({ endpoint: config.endpoint, hasApiKey: !!config.apiKey }, "Fetching Ollama models")
const client = new OllamaCloudClient(config)
const models = await client.listModels()
logger.info({ modelCount: models.length }, "Ollama models fetched successfully")
return { models }
} catch (error) {
logger.error({ error }, "Failed to list Ollama models")
return reply.status(500).send({ error: "Failed to list models" })
} catch (error: any) {
logger.error({ error: error?.message || error }, "Failed to list Ollama models")
return reply.status(500).send({ error: error?.message || "Failed to list models" })
}
})
// Get cloud models only
app.get('/api/ollama/models/cloud', async (request, reply) => {
try {
const config = getOllamaConfig()
@@ -92,7 +104,7 @@ export async function registerOllamaRoutes(
const client = new OllamaCloudClient(config)
const cloudModels = await client.getCloudModels()
return { models: cloudModels }
} catch (error) {
logger.error({ error }, "Failed to list cloud models")
@@ -100,30 +112,86 @@ export async function registerOllamaRoutes(
}
})
// Chat completion endpoint
app.get('/api/ollama/models/thinking', 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 thinkingModels = await client.getThinkingCapableModels()
return { models: thinkingModels }
} catch (error) {
logger.error({ error }, "Failed to list thinking models")
return reply.status(500).send({ error: "Failed to list thinking models" })
}
})
app.get('/api/ollama/models/vision', 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 visionModels = await client.getVisionCapableModels()
return { models: visionModels }
} catch (error) {
logger.error({ error }, "Failed to list vision models")
return reply.status(500).send({ error: "Failed to list vision models" })
}
})
app.get('/api/ollama/models/embedding', 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 embeddingModels = await client.getEmbeddingModels()
return { models: embeddingModels }
} catch (error) {
logger.error({ error }, "Failed to list embedding models")
return reply.status(500).send({ error: "Failed to list embedding models" })
}
})
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' }
body: {
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' },
think: { type: ['boolean', 'string'] },
format: { type: ['string', 'object'] },
tools: { type: 'array' },
web_search: { type: 'boolean' },
options: {
type: 'object',
properties: {
temperature: { type: 'number', minimum: 0, maximum: 2 },
top_p: { type: 'number', minimum: 0, maximum: 1 }
}
}
},
stream: { type: 'boolean' },
options: {
type: 'object',
properties: {
temperature: { type: 'number', minimum: 0, maximum: 2 },
top_p: { type: 'number', minimum: 0, maximum: 1 }
}
}
}
@@ -137,8 +205,7 @@ export async function registerOllamaRoutes(
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',
@@ -148,24 +215,31 @@ export async function registerOllamaRoutes(
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")
} catch (streamError: any) {
logger.error({ error: streamError?.message || streamError }, "Ollama streaming failed")
// Send error event to client so it knows the request failed
reply.raw.write(`data: ${JSON.stringify({ error: streamError?.message || "Streaming failed" })}\n\n`)
reply.raw.write('data: [DONE]\n\n')
reply.raw.end()
}
} else {
const response = await client.chat(chatRequest)
return response
const stream = await client.chat(chatRequest)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama chat request failed")
@@ -173,13 +247,289 @@ export async function registerOllamaRoutes(
}
})
// Pull model endpoint
app.post('/api/ollama/chat/thinking', {
schema: {
body: {
type: 'object',
required: ['model', 'messages'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
stream: { type: 'boolean' },
think: { type: ['boolean', '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 chatRequest = request.body as ChatRequest
chatRequest.think = chatRequest.think ?? true
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
try {
const stream = await client.chatWithThinking(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 }, "Thinking streaming failed")
reply.raw.end()
}
} else {
const stream = await client.chatWithThinking(chatRequest)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama thinking chat request failed")
return reply.status(500).send({ error: "Thinking chat request failed" })
}
})
app.post('/api/ollama/chat/vision', {
schema: {
body: {
type: 'object',
required: ['model', 'messages', 'images'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
images: { type: 'array', items: { type: 'string' } },
stream: { type: 'boolean' }
}
}
}
}, 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, messages, images, stream } = request.body as any
const chatRequest: ChatRequest = { model, messages, stream: stream ?? false }
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
try {
const streamResult = await client.chatWithVision(chatRequest, images)
for await (const chunk of streamResult) {
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 }, "Vision streaming failed")
reply.raw.end()
}
} else {
const streamResult = await client.chatWithVision(chatRequest, images)
const chunks: any[] = []
for await (const chunk of streamResult) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama vision chat request failed")
return reply.status(500).send({ error: "Vision chat request failed" })
}
})
app.post('/api/ollama/chat/tools', {
schema: {
body: {
type: 'object',
required: ['model', 'messages', 'tools'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
tools: { type: 'array' },
stream: { type: 'boolean' }
}
}
}
}, 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, messages, tools, stream } = request.body as any
const chatRequest: ChatRequest = { model, messages, stream: stream ?? false }
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
try {
const streamResult = await client.chatWithTools(chatRequest, tools)
for await (const chunk of streamResult) {
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 }, "Tools streaming failed")
reply.raw.end()
}
} else {
const streamResult = await client.chatWithTools(chatRequest, tools)
const chunks: any[] = []
for await (const chunk of streamResult) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama tools chat request failed")
return reply.status(500).send({ error: "Tools chat request failed" })
}
})
app.post('/api/ollama/chat/websearch', {
schema: {
body: {
type: 'object',
required: ['model', 'messages'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
stream: { type: 'boolean' }
}
}
}
}, 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
if (chatRequest.stream) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
try {
const stream = await client.chatWithWebSearch(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 }, "Web search streaming failed")
reply.raw.end()
}
} else {
const stream = await client.chatWithWebSearch(chatRequest)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
return chunks[chunks.length - 1]
}
} catch (error) {
logger.error({ error }, "Ollama web search chat request failed")
return reply.status(500).send({ error: "Web search chat request failed" })
}
})
app.post('/api/ollama/embeddings', {
schema: {
body: {
type: 'object',
required: ['model', 'input'],
properties: {
model: { type: 'string' },
input: { oneOf: [{ type: 'string' }, { type: 'array', items: { 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 embedRequest = request.body as EmbeddingRequest
const result = await client.generateEmbeddings(embedRequest)
return result
} catch (error) {
logger.error({ error }, "Ollama embeddings request failed")
return reply.status(500).send({ error: "Embeddings request failed" })
}
})
app.post('/api/ollama/pull', {
schema: {
type: 'object',
required: ['model'],
properties: {
model: { type: 'string' }
body: {
type: 'object',
required: ['model'],
properties: {
model: { type: 'string' }
}
}
}
}, async (request, reply) => {
@@ -191,12 +541,11 @@ export async function registerOllamaRoutes(
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")
@@ -207,18 +556,36 @@ export async function registerOllamaRoutes(
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" }
if (!fs.existsSync(CONFIG_FILE)) {
return { enabled: false, endpoint: "https://ollama.com" }
}
const data = fs.readFileSync(CONFIG_FILE, 'utf-8')
return JSON.parse(data)
} 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))
}
try {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true })
}
const current = getOllamaConfig()
// Only update apiKey if a new non-empty value is provided
const updated = {
...current,
...config,
// Preserve existing apiKey if new one is undefined/empty
apiKey: config.apiKey || current.apiKey
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2))
console.log(`[Ollama] Config saved: enabled=${updated.enabled}, endpoint=${updated.endpoint}, hasApiKey=${!!updated.apiKey}`)
} catch (error) {
console.error("Failed to save Ollama config:", error)
}
}

View File

@@ -5,97 +5,168 @@ interface QwenRouteDeps {
logger: Logger
}
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56'
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion'
const QWEN_OAUTH_DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
const QWEN_DEFAULT_RESOURCE_URL = 'https://dashscope.aliyuncs.com/compatible-mode'
function normalizeQwenModel(model?: string): string {
const raw = (model || "").trim()
if (!raw) return "coder-model"
const lower = raw.toLowerCase()
if (lower === "vision-model" || lower.includes("vision")) return "vision-model"
if (lower === "coder-model") return "coder-model"
if (lower.includes("coder")) return "coder-model"
return "coder-model"
}
function normalizeQwenResourceUrl(resourceUrl?: string): string {
const raw = typeof resourceUrl === 'string' && resourceUrl.trim().length > 0
? resourceUrl.trim()
: QWEN_DEFAULT_RESOURCE_URL
const withProtocol = raw.startsWith('http') ? raw : `https://${raw}`
const trimmed = withProtocol.replace(/\/$/, '')
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`
}
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', {
// Qwen OAuth Device Flow: request device authorization
app.post('/api/qwen/oauth/device', {
schema: {
type: 'object',
required: ['code', 'state'],
properties: {
code: { type: 'string' },
state: { type: 'string' },
client_id: { type: 'string' },
redirect_uri: { type: 'string' }
body: {
type: 'object',
required: ['code_challenge', 'code_challenge_method'],
properties: {
code_challenge: { type: 'string' },
code_challenge_method: { 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', {
const { code_challenge, code_challenge_method } = request.body as any
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: client_id,
code,
redirect_uri: redirect_uri
client_id: QWEN_OAUTH_CLIENT_ID,
scope: QWEN_OAUTH_SCOPE,
code_challenge,
code_challenge_method
})
})
if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.statusText}`)
if (!response.ok) {
const errorText = await response.text()
logger.error({ status: response.status, errorText }, "Qwen device authorization failed")
return reply.status(response.status).send({ error: "Device authorization failed", details: errorText })
}
const tokenData = await tokenResponse.json()
// Get user info
const userResponse = await fetch('https://qwen.ai/api/user', {
headers: {
'Authorization': `Bearer ${tokenData.access_token}`
const data = await response.json()
return { ...data }
} catch (error) {
logger.error({ error }, "Failed to request Qwen device authorization")
return reply.status(500).send({ error: "Device authorization failed" })
}
})
// Qwen OAuth Device Flow: poll token endpoint
app.post('/api/qwen/oauth/token', {
schema: {
body: {
type: 'object',
required: ['device_code', 'code_verifier'],
properties: {
device_code: { type: 'string' },
code_verifier: { type: 'string' }
}
}
}
}, async (request, reply) => {
try {
const { device_code, code_verifier } = request.body as any
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: QWEN_OAUTH_DEVICE_GRANT_TYPE,
client_id: QWEN_OAUTH_CLIENT_ID,
device_code,
code_verifier
})
})
if (!userResponse.ok) {
throw new Error(`Failed to fetch user info: ${userResponse.statusText}`)
const responseText = await response.text()
if (!response.ok) {
logger.error({ status: response.status, responseText }, "Qwen device token poll failed")
return reply.status(response.status).send(responseText)
}
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
}
try {
return reply.send(JSON.parse(responseText))
} catch {
return reply.send(responseText)
}
} catch (error) {
logger.error({ error }, "Qwen OAuth token exchange failed")
return reply.status(500).send({ error: "OAuth exchange failed" })
logger.error({ error }, "Failed to poll Qwen token endpoint")
return reply.status(500).send({ error: "Token polling failed" })
}
})
// Qwen OAuth refresh token
app.post('/api/qwen/oauth/refresh', {
schema: {
body: {
type: 'object',
required: ['refresh_token'],
properties: {
refresh_token: { type: 'string' }
}
}
}
}, async (request, reply) => {
try {
const { refresh_token } = request.body as any
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token,
client_id: QWEN_OAUTH_CLIENT_ID
})
})
const responseText = await response.text()
if (!response.ok) {
logger.error({ status: response.status, responseText }, "Qwen token refresh failed")
return reply.status(response.status).send(responseText)
}
try {
return reply.send(JSON.parse(responseText))
} catch {
return reply.send(responseText)
}
} catch (error) {
logger.error({ error }, "Failed to refresh Qwen token")
return reply.status(500).send({ error: "Token refresh failed" })
}
})
@@ -108,7 +179,7 @@ export async function registerQwenRoutes(
}
const token = authHeader.substring(7)
const userResponse = await fetch('https://qwen.ai/api/user', {
const userResponse = await fetch('https://chat.qwen.ai/api/v1/user', {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -126,9 +197,121 @@ export async function registerQwenRoutes(
}
})
// Qwen Chat API - proxy chat requests to Qwen using OAuth token
app.post('/api/qwen/chat', {
schema: {
body: {
type: 'object',
required: ['model', 'messages'],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
stream: { type: 'boolean' },
resource_url: { type: 'string' }
}
}
}
}, async (request, reply) => {
try {
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return reply.status(401).send({ error: "Authorization required" })
}
const accessToken = authHeader.substring(7)
const { model, messages, stream, resource_url } = request.body as any
// Use resource_url from OAuth credentials to target the DashScope-compatible API
const apiBaseUrl = normalizeQwenResourceUrl(resource_url)
const normalizedModel = normalizeQwenModel(model)
const chatUrl = `${apiBaseUrl}/chat/completions`
logger.info({ chatUrl, model: normalizedModel, messageCount: messages?.length }, "Proxying Qwen chat request")
const response = await fetch(chatUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'Accept': stream ? 'text/event-stream' : 'application/json'
},
body: JSON.stringify({
model: normalizedModel,
messages,
stream: stream || false
})
})
if (!response.ok) {
const errorText = await response.text()
logger.error({ status: response.status, errorText }, "Qwen chat request failed")
return reply.status(response.status).send({ error: "Chat request failed", details: errorText })
}
if (stream && response.body) {
// Stream the response
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
reply.raw.write(chunk)
}
} finally {
reader.releaseLock()
reply.raw.end()
}
} else {
const data = await response.json()
return reply.send(data)
}
} catch (error) {
logger.error({ error }, "Qwen chat proxy failed")
return reply.status(500).send({ error: "Chat request failed" })
}
})
// Qwen Models list endpoint
app.get('/api/qwen/models', async (request, reply) => {
try {
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return reply.status(401).send({ error: "Authorization required" })
}
const accessToken = authHeader.substring(7)
const resourceUrl = (request.query as any).resource_url || 'https://chat.qwen.ai'
const modelsUrl = `${resourceUrl}/api/v1/models`
const response = await fetch(modelsUrl, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json'
}
})
if (!response.ok) {
const errorText = await response.text()
logger.error({ status: response.status, errorText }, "Qwen models request failed")
return reply.status(response.status).send({ error: "Models request failed", details: errorText })
}
const data = await response.json()
return reply.send(data)
} catch (error) {
logger.error({ error }, "Qwen models request failed")
return reply.status(500).send({ error: "Models request failed" })
}
})
logger.info("Qwen OAuth routes registered")
}
function generateState(): string {
return Math.random().toString(36).substring(2, 15) + Date.now().toString(36)
}

View File

@@ -24,12 +24,29 @@ const InstanceDataSchema = z.object({
messageHistory: z.array(z.string()).default([]),
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
sessionTasks: z.record(z.string(), z.array(TaskSchema)).optional(),
sessionSkills: z
.record(
z.string(),
z.array(z.object({ id: z.string(), name: z.string(), description: z.string().optional() })),
)
.optional(),
customAgents: z
.array(
z.object({
name: z.string(),
description: z.string().optional(),
prompt: z.string(),
}),
)
.optional(),
})
const EMPTY_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
sessionTasks: {},
sessionSkills: {},
customAgents: [],
}
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {

View File

@@ -1,10 +1,18 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { spawnSync } from "child_process"
import { z } from "zod"
import { existsSync, mkdirSync } from "fs"
import { cp, readFile, writeFile } from "fs/promises"
import path from "path"
import { WorkspaceManager } from "../../workspaces/manager"
import { InstanceStore } from "../../storage/instance-store"
import { ConfigStore } from "../../config/store"
import { getWorkspaceOpencodeConfigDir } from "../../opencode-config"
interface RouteDeps {
workspaceManager: WorkspaceManager
instanceStore: InstanceStore
configStore: ConfigStore
}
const WorkspaceCreateSchema = z.object({
@@ -163,6 +171,143 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return { isRepo: true, branch, ahead, behind, changes }
})
app.post<{
Params: { id: string }
Body: { destination: string; includeConfig?: boolean }
}>("/api/workspaces/:id/export", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const payload = request.body ?? { destination: "" }
const destination = payload.destination?.trim()
if (!destination) {
reply.code(400)
return { error: "Destination is required" }
}
const exportRoot = path.join(destination, `nomadarch-export-${path.basename(workspace.path)}-${Date.now()}`)
mkdirSync(exportRoot, { recursive: true })
const workspaceTarget = path.join(exportRoot, "workspace")
await cp(workspace.path, workspaceTarget, { recursive: true, force: true })
const instanceData = await deps.instanceStore.read(workspace.path)
await writeFile(path.join(exportRoot, "instance-data.json"), JSON.stringify(instanceData, null, 2), "utf-8")
const configDir = getWorkspaceOpencodeConfigDir(workspace.id)
if (existsSync(configDir)) {
await cp(configDir, path.join(exportRoot, "opencode-config"), { recursive: true, force: true })
}
if (payload.includeConfig) {
const config = deps.configStore.get()
await writeFile(path.join(exportRoot, "user-config.json"), JSON.stringify(config, null, 2), "utf-8")
}
const metadata = {
exportedAt: new Date().toISOString(),
workspacePath: workspace.path,
workspaceId: workspace.id,
}
await writeFile(path.join(exportRoot, "metadata.json"), JSON.stringify(metadata, null, 2), "utf-8")
return { destination: exportRoot }
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id/mcp-config", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const configPath = path.join(workspace.path, ".mcp.json")
if (!existsSync(configPath)) {
return { path: configPath, exists: false, config: { mcpServers: {} } }
}
try {
const raw = await readFile(configPath, "utf-8")
const parsed = raw ? JSON.parse(raw) : {}
return { path: configPath, exists: true, config: parsed }
} catch (error) {
request.log.error({ err: error }, "Failed to read MCP config")
reply.code(500)
return { error: "Failed to read MCP config" }
}
})
app.put<{ Params: { id: string } }>("/api/workspaces/:id/mcp-config", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const body = request.body as { config?: unknown }
if (!body || typeof body.config !== "object" || body.config === null) {
reply.code(400)
return { error: "Invalid MCP config payload" }
}
const configPath = path.join(workspace.path, ".mcp.json")
try {
await writeFile(configPath, JSON.stringify(body.config, null, 2), "utf-8")
return { path: configPath, exists: true, config: body.config }
} catch (error) {
request.log.error({ err: error }, "Failed to write MCP config")
reply.code(500)
return { error: "Failed to write MCP config" }
}
})
app.post<{
Body: { source: string; destination: string; includeConfig?: boolean }
}>("/api/workspaces/import", async (request, reply) => {
const payload = request.body ?? { source: "", destination: "" }
const source = payload.source?.trim()
const destination = payload.destination?.trim()
if (!source || !destination) {
reply.code(400)
return { error: "Source and destination are required" }
}
const workspaceSource = path.join(source, "workspace")
if (!existsSync(workspaceSource)) {
reply.code(400)
return { error: "Export workspace folder not found" }
}
await cp(workspaceSource, destination, { recursive: true, force: true })
const workspace = await deps.workspaceManager.create(destination)
const instanceDataPath = path.join(source, "instance-data.json")
if (existsSync(instanceDataPath)) {
const raw = await readFile(instanceDataPath, "utf-8")
await deps.instanceStore.write(workspace.path, JSON.parse(raw))
}
const configSource = path.join(source, "opencode-config")
if (existsSync(configSource)) {
const configTarget = getWorkspaceOpencodeConfigDir(workspace.id)
await cp(configSource, configTarget, { recursive: true, force: true })
}
if (payload.includeConfig) {
const userConfigPath = path.join(source, "user-config.json")
if (existsSync(userConfigPath)) {
const raw = await readFile(userConfigPath, "utf-8")
deps.configStore.replace(JSON.parse(raw))
}
}
return workspace
})
}

View File

@@ -1,16 +1,15 @@
import { FastifyInstance } from "fastify"
import { ZAIClient, type ZAIConfig, type ZAIChatRequest } from "../../integrations/zai-api"
import { ZAIClient, ZAI_MODELS, type ZAIConfig, type ZAIChatRequest, ZAIChatRequestSchema } from "../../integrations/zai-api"
import { Logger } from "../../logger"
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
import { join } from "path"
import { homedir } from "os"
import { getUserIntegrationsDir } from "../../user-data"
interface ZAIRouteDeps {
logger: Logger
}
// Config file path
const CONFIG_DIR = join(homedir(), ".nomadarch")
const CONFIG_DIR = getUserIntegrationsDir()
const CONFIG_FILE = join(CONFIG_DIR, "zai-config.json")
export async function registerZAIRoutes(
@@ -69,15 +68,7 @@ export async function registerZAIRoutes(
// List available models
app.get('/api/zai/models', async (request, reply) => {
try {
const config = getZAIConfig()
if (!config.enabled) {
return reply.status(400).send({ error: "Z.AI is not enabled" })
}
const client = new ZAIClient(config)
const models = await client.listModels()
return { models: models.map(name => ({ name, provider: "zai" })) }
return { models: ZAI_MODELS.map(name => ({ name, provider: "zai" })) }
} catch (error) {
logger.error({ error }, "Failed to list Z.AI models")
return reply.status(500).send({ error: "Failed to list models" })
@@ -107,8 +98,9 @@ export async function registerZAIRoutes(
for await (const chunk of client.chatStream(chatRequest)) {
reply.raw.write(`data: ${JSON.stringify(chunk)}\n\n`)
// Check for message_stop event
if (chunk.type === "message_stop") {
// Check for finish_reason to end stream
const finishReason = chunk.choices[0]?.finish_reason
if (finishReason) {
reply.raw.write('data: [DONE]\n\n')
break
}
@@ -133,16 +125,15 @@ export async function registerZAIRoutes(
logger.info("Z.AI routes registered")
}
// Configuration management functions using file-based storage
function getZAIConfig(): ZAIConfig {
try {
if (existsSync(CONFIG_FILE)) {
const data = readFileSync(CONFIG_FILE, 'utf-8')
return JSON.parse(data)
}
return { enabled: false, endpoint: "https://api.z.ai/api/anthropic", timeout: 3000000 }
return { enabled: false, endpoint: "https://api.z.ai/api/paas/v4", timeout: 300000 }
} catch {
return { enabled: false, endpoint: "https://api.z.ai/api/anthropic", timeout: 3000000 }
return { enabled: false, endpoint: "https://api.z.ai/api/paas/v4", timeout: 300000 }
}
}

View File

@@ -1,8 +1,8 @@
import fs from "fs"
import { promises as fsp } from "fs"
import os from "os"
import path from "path"
import type { InstanceData } from "../api-types"
import { getUserInstancesDir } from "../user-data"
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
@@ -13,7 +13,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
export class InstanceStore {
private readonly instancesDir: string
constructor(baseDir = path.join(os.homedir(), ".config", "codenomad", "instances")) {
constructor(baseDir = getUserInstancesDir()) {
this.instancesDir = baseDir
fs.mkdirSync(this.instancesDir, { recursive: true })
}

View File

@@ -0,0 +1,28 @@
import os from "os"
import path from "path"
const DEFAULT_ROOT = path.join(os.homedir(), ".config", "codenomad")
export function getUserDataRoot(): string {
const override = process.env.CODENOMAD_USER_DIR
if (override && override.trim().length > 0) {
return path.resolve(override)
}
return DEFAULT_ROOT
}
export function getUserConfigPath(): string {
return path.join(getUserDataRoot(), "config.json")
}
export function getUserInstancesDir(): string {
return path.join(getUserDataRoot(), "instances")
}
export function getUserIntegrationsDir(): string {
return path.join(getUserDataRoot(), "integrations")
}
export function getOpencodeWorkspacesRoot(): string {
return path.join(getUserDataRoot(), "opencode-workspaces")
}

View File

@@ -0,0 +1,35 @@
import net from "net"
const DEFAULT_START_PORT = 3000
const MAX_PORT_ATTEMPTS = 50
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
server.once("error", () => {
resolve(false)
})
server.once("listening", () => {
server.close()
resolve(true)
})
server.listen(port, "127.0.0.1")
})
}
export async function findAvailablePort(startPort: number = DEFAULT_START_PORT): Promise<number> {
for (let port = startPort; port < startPort + MAX_PORT_ATTEMPTS; port++) {
if (await isPortAvailable(port)) {
return port
}
}
return 0
}
export async function getAvailablePort(preferredPort: number = DEFAULT_START_PORT): Promise<number> {
const isAvailable = await isPortAvailable(preferredPort)
if (isAvailable) {
return preferredPort
}
return findAvailablePort(preferredPort + 1)
}

View File

@@ -10,7 +10,7 @@ import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config"
import { ensureWorkspaceOpencodeConfig } from "../opencode-config"
const STARTUP_STABILITY_DELAY_MS = 1500
@@ -27,11 +27,9 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
list(): WorkspaceDescriptor[] {
@@ -105,9 +103,10 @@ export class WorkspaceManager {
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const opencodeConfigDir = ensureWorkspaceOpencodeConfig(id)
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
OPENCODE_CONFIG_DIR: opencodeConfigDir,
}
try {

View File

@@ -7,7 +7,8 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json"
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "node --test --experimental-strip-types src/lib/__tests__/*.test.ts src/stores/__tests__/*.test.ts"
},
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
@@ -30,8 +31,10 @@
"autoprefixer": "10.4.21",
"postcss": "8.5.6",
"tailwindcss": "3",
"tsx": "^4.21.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
"vite-plugin-solid": "^2.10.0",
"zod": "^3.25.76"
}
}

View File

@@ -24,6 +24,8 @@ import {
setIsSelectingFolder,
showFolderSelection,
setShowFolderSelection,
showFolderSelectionOnStart,
setShowFolderSelectionOnStart,
} from "./stores/ui"
import { useConfig } from "./stores/preferences"
import {
@@ -74,6 +76,8 @@ const App: Component = () => {
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const shouldShowFolderSelection = () => !hasInstances() || showFolderSelectionOnStart()
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
@@ -156,6 +160,7 @@ const App: Component = () => {
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setShowFolderSelection(false)
setShowFolderSelectionOnStart(false)
setIsAdvancedSettingsOpen(false)
log.info("Created instance", {
@@ -375,7 +380,7 @@ const App: Component = () => {
</Dialog>
<div class="h-screen w-screen flex flex-col">
<Show
when={!hasInstances()}
when={shouldShowFolderSelection()}
fallback={
<>
<InstanceTabs
@@ -432,6 +437,7 @@ const App: Component = () => {
<button
onClick={() => {
setShowFolderSelection(false)
setShowFolderSelectionOnStart(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}

View File

@@ -1,13 +1,15 @@
import { createSignal, Show, onMount, For, createMemo, createEffect } from "solid-js";
import { createSignal, Show, onMount, For, createMemo, createEffect, onCleanup } 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 { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession } from "@/stores/session-actions";
import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions";
import { messageStoreBus } from "@/stores/message-v2/bus";
import MessageBlockList from "@/components/message-block-list";
import MessageBlockList, { getMessageAnchorId } from "@/components/message-block-list";
import { formatTokenTotal } from "@/lib/formatters";
import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval, toggleApex } from "@/stores/solo-store";
import { getLogger } from "@/lib/logger";
import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction";
import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events";
import {
Command,
Plus,
@@ -35,10 +37,18 @@ import {
User,
Settings,
Key,
FileArchive,
Paperclip,
} from "lucide-solid";
import ModelSelector from "@/components/model-selector";
import AgentSelector from "@/components/agent-selector";
import AttachmentChip from "@/components/attachment-chip";
import { createFileAttachment } from "@/types/attachment";
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
import type { Task } from "@/types/session";
const OPEN_ADVANCED_SETTINGS_EVENT = "open-advanced-settings";
const log = getLogger("multix-chat");
interface MultiTaskChatProps {
@@ -51,17 +61,29 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
const [isSending, setIsSending] = createSignal(false);
const [chatInput, setChatInput] = createSignal("");
const [isCompacting, setIsCompacting] = createSignal(false);
const [attachments, setAttachments] = createSignal<ReturnType<typeof createFileAttachment>[]>([]);
let scrollContainer: HTMLDivElement | undefined;
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
const [showApiManager, setShowApiManager] = createSignal(false);
const [userScrolling, setUserScrolling] = createSignal(false);
const [lastScrollTop, setLastScrollTop] = createSignal(0);
let fileInputRef: HTMLInputElement | undefined;
// Scroll to bottom helper
const scrollToBottom = () => {
if (scrollContainer) {
if (scrollContainer && !userScrolling()) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
};
// Track if user is manually scrolling (not at bottom)
const checkUserScrolling = () => {
if (!scrollContainer) return false;
const threshold = 50;
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < threshold;
return !isAtBottom;
};
// Get current session and tasks
const session = () => {
const instanceSessions = sessions().get(props.instanceId);
@@ -69,7 +91,8 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
};
const tasks = () => session()?.tasks || [];
const selectedTask = () => tasks().find(t => t.id === selectedTaskId());
const visibleTasks = createMemo(() => tasks().filter((task) => !task.archived));
const selectedTask = () => visibleTasks().find((task) => task.id === selectedTaskId());
// Message store integration
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
@@ -114,19 +137,20 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
return {
used: usage?.actualUsageTokens ?? 0,
total: usage?.totalCost ?? 0,
input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0,
reasoning: usage?.reasoningTokens ?? 0,
cacheRead: usage?.cacheReadTokens ?? 0,
cacheWrite: usage?.cacheWriteTokens ?? 0,
// input: usage?.inputTokens ?? 0,
// output: usage?.outputTokens ?? 0,
// reasoning: usage?.reasoningTokens ?? 0,
// cacheRead: usage?.cacheReadTokens ?? 0,
// cacheWrite: usage?.cacheWriteTokens ?? 0,
cost: usage?.totalCost ?? 0,
};
});
// Get current model from instance
// Get current model from active task session
const currentModel = createMemo(() => {
const instance = instances().get(props.instanceId);
return instance?.modelId || "unknown";
const instanceSessions = sessions().get(props.instanceId);
const session = instanceSessions?.get(activeTaskSessionId());
return session?.model?.modelId || "unknown";
});
const activeTaskSessionId = createMemo(() => {
@@ -134,6 +158,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
return task?.taskSessionId || props.sessionId;
});
const activeTaskSession = createMemo(() => {
const instanceSessions = sessions().get(props.instanceId);
return instanceSessions?.get(activeTaskSessionId());
});
const currentTaskAgent = createMemo(() => activeTaskSession()?.agent || "");
const currentTaskModel = createMemo(() => activeTaskSession()?.model || { providerId: "", modelId: "" });
const compactionSuggestion = createMemo(() => {
const sessionId = activeTaskSessionId();
return getCompactionSuggestion(props.instanceId, sessionId);
});
const hasCompactionSuggestion = createMemo(() => Boolean(compactionSuggestion()));
const solo = () => getSoloState(props.instanceId);
// APEX PRO mode = SOLO + APEX combined (autonomous + auto-approval)
@@ -181,8 +220,12 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const streaming = isAgentThinking();
if (!streaming) return;
// During streaming, scroll periodically to keep up with content
const interval = setInterval(scrollToBottom, 300);
// During streaming, scroll periodically to keep up with content (unless user is scrolling)
const interval = setInterval(() => {
if (!userScrolling()) {
scrollToBottom();
}
}, 300);
return () => clearInterval(interval);
});
@@ -191,14 +234,40 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
const ids = filteredMessageIds();
const thinking = isAgentThinking();
// Scroll when message count changes or when thinking starts
if (ids.length > 0 || thinking) {
// Scroll when message count changes or when thinking starts (unless user is scrolling)
if ((ids.length > 0 || thinking) && !userScrolling()) {
requestAnimationFrame(() => {
setTimeout(scrollToBottom, 50);
});
}
});
// Scroll event listener to detect user scrolling
onMount(() => {
const handleScroll = () => {
if (scrollContainer) {
const isScrollingUp = scrollContainer.scrollTop < lastScrollTop();
const isScrollingDown = scrollContainer.scrollTop > lastScrollTop();
setLastScrollTop(scrollContainer.scrollTop);
// If user scrolls up or scrolls away from bottom, set userScrolling flag
if (checkUserScrolling()) {
setUserScrolling(true);
} else {
// User is back at bottom, reset the flag
setUserScrolling(false);
}
}
};
const container = scrollContainer;
container?.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container?.removeEventListener('scroll', handleScroll);
};
});
const handleSendMessage = async () => {
const message = chatInput().trim();
if (!message || isSending()) return;
@@ -253,12 +322,13 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
props.instanceId,
targetSessionId,
message,
[],
attachments(),
taskId || undefined
);
log.info("sendMessage call completed");
setChatInput("");
setAttachments([]);
// Auto-scroll to bottom after sending
setTimeout(scrollToBottom, 100);
@@ -271,6 +341,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
}
};
const handleCreateTask = async () => {
if (isSending()) return;
setChatInput("");
try {
const nextIndex = tasks().length + 1;
const title = `Task ${nextIndex}`;
const result = await addTask(props.instanceId, props.sessionId, title);
setSelectedTaskId(result.id);
setTimeout(scrollToBottom, 50);
} catch (error) {
log.error("handleCreateTask failed", error);
console.error("[MultiTaskChat] Task creation failed:", error);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
// Enter to submit, Shift+Enter for new line
if (e.key === "Enter" && !e.shiftKey) {
@@ -298,8 +383,64 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
}
};
const handleOpenAdvancedSettings = () => {
// Dispatch custom event to trigger Advanced Settings modal from parent
window.dispatchEvent(new CustomEvent(OPEN_ADVANCED_SETTINGS_EVENT, {
detail: { instanceId: props.instanceId, sessionId: props.sessionId }
}));
};
const handleCompact = async () => {
const targetSessionId = activeTaskSessionId();
if (isCompacting()) return;
setIsCompacting(true);
log.info("Compacting session", { instanceId: props.instanceId, sessionId: targetSessionId });
try {
clearCompactionSuggestion(props.instanceId, targetSessionId);
await compactSession(props.instanceId, targetSessionId);
log.info("Session compacted successfully");
} catch (error) {
log.error("Failed to compact session", error);
console.error("[MultiTaskChat] Compact failed:", error);
} finally {
setIsCompacting(false);
log.info("Compact operation finished");
}
};
const addAttachment = (attachment: ReturnType<typeof createFileAttachment>) => {
setAttachments((prev) => [...prev, attachment]);
};
const removeAttachment = (attachmentId: string) => {
setAttachments((prev) => prev.filter((item) => item.id !== attachmentId));
};
const handleFileSelect = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
Array.from(input.files).forEach((file) => {
const reader = new FileReader();
reader.onload = () => {
const buffer = reader.result instanceof ArrayBuffer ? reader.result : null;
const data = buffer ? new Uint8Array(buffer) : undefined;
const attachment = createFileAttachment(file.name, file.name, file.type || "application/octet-stream", data);
if (file.type.startsWith("image/") && typeof reader.result === "string") {
attachment.url = reader.result;
}
addAttachment(attachment);
};
reader.readAsArrayBuffer(file);
});
input.value = "";
};
return (
<main class="h-full max-h-full flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
<main class="absolute inset-0 flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
{/* 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">
@@ -309,6 +450,14 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<Zap size={10} class="text-white fill-current" />
</div>
</div>
<button
onClick={() => emitSessionSidebarRequest({ instanceId: props.instanceId, action: "show-skills" })}
class="flex items-center space-x-1.5 px-2.5 py-1.5 rounded-lg border border-white/10 bg-white/5 text-zinc-400 hover:text-indigo-300 hover:border-indigo-500/30 hover:bg-indigo-500/10 transition-all"
title="Open Skills"
>
<Sparkles size={12} class="text-indigo-400" />
<span class="text-[10px] font-black uppercase tracking-tight">Skills</span>
</button>
<Show when={selectedTaskId()}>
<div class="flex items-center space-x-2 animate-in fade-in slide-in-from-left-2 duration-300">
@@ -351,9 +500,25 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</div>
</Show>
{/* API Key Manager Button */}
{/* Compact Button - Context Compression & Summary */}
<button
onClick={() => setShowApiManager(true)}
onClick={handleCompact}
class={`flex items-center space-x-1.5 px-2.5 py-1.5 transition-all rounded-xl active:scale-95 border ${isCompacting()
? "text-blue-400 bg-blue-500/15 border-blue-500/40 animate-pulse shadow-[0_0_20px_rgba(59,130,246,0.3)]"
: hasCompactionSuggestion()
? "text-emerald-300 bg-emerald-500/20 border-emerald-500/50 shadow-[0_0_16px_rgba(34,197,94,0.35)] animate-pulse"
: "text-zinc-500 hover:text-blue-400 hover:bg-blue-500/10 border-transparent hover:border-blue-500/30"
}`}
title={isCompacting() ? "Compacting session (compressing context & creating summary)..." : "Compact session - Compress context & create summary"}
disabled={isCompacting()}
>
<FileArchive size={16} strokeWidth={2} />
<span class="text-[10px] font-bold uppercase tracking-tight">{isCompacting() ? "Compacting..." : "Compact"}</span>
</button>
{/* API Key Manager Button - Opens Advanced Settings */}
<button
onClick={handleOpenAdvancedSettings}
class="p-2 text-zinc-500 hover:text-emerald-400 transition-all hover:bg-emerald-500/10 rounded-xl active:scale-90"
title="API Key Manager"
>
@@ -369,7 +534,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</header>
{/* Task Tabs (Horizontal Scroll) */}
<Show when={tasks().length > 0}>
<Show when={visibleTasks().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)}
@@ -385,7 +550,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<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()}>
<For each={visibleTasks()}>
{(task) => (
<button
onClick={() => setSelectedTaskId(task.id)}
@@ -399,6 +564,18 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
"bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.4)] animate-pulse"
}`} />
<span class="truncate">{task.title}</span>
<span
role="button"
tabindex={0}
onClick={(event) => {
event.stopPropagation();
archiveTask(props.instanceId, props.sessionId, task.id);
}}
class="opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-200 transition-colors"
title="Archive task"
>
<X size={12} />
</span>
<Show when={selectedTaskId() === task.id}>
<div class="ml-1 w-1 h-1 bg-indigo-400 rounded-full animate-ping" />
</Show>
@@ -409,8 +586,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<button
onClick={() => {
setChatInput("");
setSelectedTaskId(null);
handleCreateTask();
}}
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"
@@ -420,6 +596,25 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</div>
</Show>
<Show when={selectedTask()}>
<div class="px-4 py-3 border-b border-white/5 bg-zinc-950/40">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<AgentSelector
instanceId={props.instanceId}
sessionId={activeTaskSessionId()}
currentAgent={currentTaskAgent()}
onAgentChange={(agent) => updateSessionAgent(props.instanceId, activeTaskSessionId(), agent)}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={activeTaskSessionId()}
currentModel={currentTaskModel()}
onModelChange={(model) => updateSessionModelForSession(props.instanceId, activeTaskSessionId(), model)}
/>
</div>
</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">
{/* Main chat area */}
@@ -428,6 +623,18 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
ref={scrollContainer}
class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar"
>
<Show when={hasCompactionSuggestion()}>
<div class="mx-3 mt-3 mb-1 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-[11px] text-emerald-200 flex items-center justify-between gap-3">
<span class="font-semibold">Compact suggested: {compactionSuggestion()?.reason}</span>
<button
type="button"
class="px-2.5 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wide bg-emerald-500/20 border border-emerald-500/40 text-emerald-200 hover:bg-emerald-500/30 transition-colors"
onClick={handleCompact}
>
Compact now
</button>
</div>
</Show>
<Show when={!selectedTaskId()} fallback={
<div class="p-3 pb-4 overflow-x-hidden">
<MessageBlockList
@@ -456,12 +663,12 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<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}
{visibleTasks().length}
</span>
</div>
<div class="grid gap-3">
<For each={tasks()} fallback={
<For each={visibleTasks()} 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} />
@@ -491,7 +698,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<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" />
<div class="flex items-center space-x-2">
<span
role="button"
tabindex={0}
onClick={(event) => {
event.stopPropagation();
archiveTask(props.instanceId, props.sessionId, task.id);
}}
class="text-zinc-600 hover:text-zinc-200 transition-colors"
title="Archive task"
>
<X size={14} />
</span>
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
</div>
</button>
)}
</For>
@@ -572,20 +793,22 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "THINKING" : "SENDING"}</span>
</div>
</Show>
{/* STOP button */}
<Show when={isAgentThinking()}>
<button
onClick={handleStopAgent}
class="flex items-center space-x-1 px-2 py-0.5 bg-rose-500/20 hover:bg-rose-500/30 rounded border border-rose-500/40 text-[9px] font-bold text-rose-400 transition-all"
title="Stop agent"
>
<StopCircle size={10} />
<span>STOP</span>
</button>
</Show>
</div>
</div>
<Show when={attachments().length > 0}>
<div class="flex flex-wrap gap-2 mb-2">
<For each={attachments()}>
{(attachment) => (
<AttachmentChip
attachment={attachment}
onRemove={() => removeAttachment(attachment.id)}
/>
)}
</For>
</div>
</Show>
{/* Text Input */}
<textarea
value={chatInput()}
@@ -601,49 +824,32 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<div class="flex items-center justify-between pt-2 border-t border-white/5 mt-2">
<div class="flex items-center space-x-2 flex-wrap gap-y-1">
{/* Detailed token stats */}
<Show when={tokenStats().input > 0 || tokenStats().output > 0}>
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">INPUT</span>
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().input.toLocaleString()}</span>
</div>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">OUTPUT</span>
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().output.toLocaleString()}</span>
</div>
<Show when={tokenStats().reasoning > 0}>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">REASONING</span>
<span class="text-[9px] font-bold text-amber-400">{tokenStats().reasoning.toLocaleString()}</span>
</div>
</Show>
<Show when={tokenStats().cacheRead > 0}>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE READ</span>
<span class="text-[9px] font-bold text-emerald-400">{tokenStats().cacheRead.toLocaleString()}</span>
</div>
</Show>
<Show when={tokenStats().cacheWrite > 0}>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE WRITE</span>
<span class="text-[9px] font-bold text-cyan-400">{tokenStats().cacheWrite.toLocaleString()}</span>
</div>
</Show>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">COST</span>
<span class="text-[9px] font-bold text-violet-400">${tokenStats().cost.toFixed(4)}</span>
</div>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
</div>
</Show>
<Show when={!(tokenStats().input > 0 || tokenStats().output > 0)}>
{/* Detailed breakdown not available */}
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">COST</span>
<span class="text-[9px] font-bold text-violet-400">${tokenStats().cost.toFixed(4)}</span>
</div>
<div class="w-px h-3 bg-zinc-800" />
<div class="flex items-center space-x-1.5">
<span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
</div>
<div class="flex items-center space-x-1.5">
<input
ref={fileInputRef}
type="file"
multiple
class="sr-only"
onChange={handleFileSelect}
/>
<button
type="button"
onClick={() => fileInputRef?.click()}
class="text-zinc-600 hover:text-indigo-300 transition-colors p-1"
title="Attach files"
>
<Paperclip size={14} />
</button>
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
<Hash size={14} />
</button>
@@ -655,23 +861,35 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
<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>
</Show>
</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" />
<div class="flex items-center space-x-2">
<Show when={isAgentThinking() || isSending()}>
<button
onClick={handleStopAgent}
class="px-3 py-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 rounded-lg text-[10px] font-bold uppercase tracking-wide transition-all border border-rose-500/30"
title="Stop response"
>
<StopCircle size={12} class="inline-block mr-1" />
Stop
</button>
</Show>
</button>
<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>
@@ -679,30 +897,37 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
{/* Message Navigation Sidebar - YOU/ASST labels with hover preview */}
<Show when={selectedTaskId() && filteredMessageIds().length > 0}>
<div class="w-14 shrink-0 bg-zinc-900/40 border-l border-white/5 overflow-y-auto py-2 px-1.5 flex flex-col items-center gap-1">
<div class="w-14 shrink-0 bg-zinc-900/40 border-l border-white/5 overflow-hidden py-2 px-1.5 flex flex-col items-center gap-1">
<For each={filteredMessageIds()}>
{(messageId, index) => {
const msg = () => messageStore().getMessage(messageId);
const isUser = () => msg()?.role === "user";
const [showPreview, setShowPreview] = createSignal(false);
// Get message preview text (first 100 chars)
// Get message preview text (first 150 chars)
const previewText = () => {
const message = msg();
if (!message) return "";
const content = message.parts?.[0]?.content || message.content || "";
const content = (message.parts?.[0] as any)?.text || (message.parts?.[0] as any)?.content || (message as any).content || "";
const text = typeof content === "string" ? content : JSON.stringify(content);
return text.length > 100 ? text.substring(0, 100) + "..." : text;
return text.length > 150 ? text.substring(0, 150) + "..." : text;
};
const handleTabClick = () => {
const anchorId = getMessageAnchorId(messageId);
const element = scrollContainer?.querySelector(`#${anchorId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
// Highlight the message briefly
element.classList.add("message-highlight");
setTimeout(() => element.classList.remove("message-highlight"), 2000);
}
};
return (
<div class="relative group">
<button
onClick={() => {
// Scroll to message
const element = document.getElementById(`msg-${messageId}`);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
}}
onClick={handleTabClick}
onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)}
class={`w-10 py-1.5 rounded text-[8px] font-black uppercase transition-all cursor-pointer ${isUser()
@@ -715,11 +940,16 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
{/* Hover Preview Tooltip */}
<Show when={showPreview()}>
<div class="absolute right-full mr-2 top-0 w-64 max-h-32 overflow-hidden bg-zinc-900 border border-white/10 rounded-lg shadow-xl p-2 z-50 animate-in fade-in slide-in-from-right-2 duration-150">
<div class={`text-[9px] font-bold uppercase mb-1 ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
{isUser() ? "You" : "Assistant"} Message {index() + 1}
<div class="absolute right-full mr-2 top-0 w-72 max-h-40 overflow-y-auto bg-zinc-900 border border-white/10 rounded-lg shadow-xl p-3 z-50 animate-in fade-in slide-in-from-right-2 duration-150 custom-scrollbar">
<div class="flex items-center justify-between mb-2">
<div class={`text-[9px] font-bold uppercase ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
{isUser() ? "You" : "Assistant"} Msg {index() + 1}
</div>
<div class="text-[8px] text-zinc-600">
{msg()?.status === "streaming" ? "• Streaming" : ""}
</div>
</div>
<p class="text-[11px] text-zinc-300 leading-relaxed line-clamp-4">
<p class="text-[10px] text-zinc-300 leading-relaxed whitespace-pre-wrap">
{previewText()}
</p>
</div>
@@ -732,79 +962,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
</Show>
</div>
{/* API Key Manager Modal */}
<Show when={showApiManager()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowApiManager(false)}>
<div class="w-full max-w-2xl bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()}>
<header class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center">
<Key size={20} class="text-white" />
</div>
<div>
<h2 class="text-lg font-bold text-white">API Key Manager</h2>
<p class="text-xs text-zinc-500">Manage your access tokens for various AI providers</p>
</div>
</div>
<button onClick={() => setShowApiManager(false)} class="p-2 hover:bg-white/10 rounded-lg transition-colors">
<X size={20} class="text-zinc-400" />
</button>
</header>
<div class="flex h-[400px]">
{/* Sidebar */}
<div class="w-48 bg-zinc-950/50 border-r border-white/5 p-3 space-y-1">
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest px-2 py-1">Built-in</div>
<button class="w-full text-left px-3 py-2 rounded-lg bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 text-sm font-medium">
NomadArch (Free)
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
Ollama Cloud
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
OpenAI
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
Anthropic
</button>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
OpenRouter
</button>
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest px-2 py-1 mt-4">Custom</div>
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors flex items-center space-x-2">
<Plus size={14} />
<span>Add Custom Provider</span>
</button>
</div>
{/* Content */}
<div class="flex-1 p-6 flex flex-col items-center justify-center">
<div class="w-16 h-16 rounded-2xl bg-emerald-500/20 flex items-center justify-center mb-4">
<Shield size={32} class="text-emerald-400" />
</div>
<h3 class="text-xl font-bold text-white mb-2">NomadArch Managed Models</h3>
<p class="text-sm text-zinc-400 text-center max-w-sm mb-6">
These models are provided free of charge as part of the NomadArch platform. No API key or configuration is required to use them.
</p>
<div class="bg-zinc-800/50 rounded-xl p-4 w-full max-w-sm space-y-3">
<div class="flex justify-between text-sm">
<span class="text-zinc-500">Providers</span>
<span class="text-white font-medium">Qwen, DeepSeek, Google</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-500">Rate Limit</span>
<span class="text-white font-medium">Generous / Unlimited</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-500">Status</span>
<span class="text-emerald-400 font-bold">ACTIVE</span>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
</main>
</main >
);
}

View File

@@ -5,8 +5,9 @@ import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { users, activeUser, refreshUsers, createUser, updateUser, deleteUser, loginUser, createGuest } from "../stores/users"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const nomadArchLogo = new URL("../images/NomadArch-Icon.png", import.meta.url).href
interface FolderSelectionViewProps {
@@ -24,9 +25,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const [showUserModal, setShowUserModal] = createSignal(false)
const [newUserName, setNewUserName] = createSignal("")
const [newUserPassword, setNewUserPassword] = createSignal("")
const [loginPassword, setLoginPassword] = createSignal("")
const [loginTargetId, setLoginTargetId] = createSignal<string | null>(null)
const [userError, setUserError] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders()
const isLoading = () => Boolean(props.isLoading)
@@ -153,6 +160,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
refreshUsers()
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
@@ -174,7 +182,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isLoading()) return
props.onSelectFolder(path, selectedBinary())
}
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
@@ -191,17 +199,48 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}
setIsFolderBrowserOpen(true)
}
function handleBrowserSelect(path: string) {
setIsFolderBrowserOpen(false)
handleFolderSelect(path)
}
function handleBinaryChange(binary: string) {
setSelectedBinary(binary)
}
async function handleCreateUser() {
const name = newUserName().trim()
const password = newUserPassword()
if (!name || password.length < 4) {
setUserError("Provide a name and a 4+ character password.")
return
}
setUserError(null)
await createUser(name, password)
setNewUserName("")
setNewUserPassword("")
}
async function handleLogin(userId: string) {
const password = loginTargetId() === userId ? loginPassword() : ""
const ok = await loginUser(userId, password)
if (!ok) {
setUserError("Invalid password.")
return
}
setUserError(null)
setLoginPassword("")
setLoginTargetId(null)
setShowUserModal(false)
}
async function handleGuest() {
await createGuest()
setShowUserModal(false)
}
function handleRemove(path: string, e?: Event) {
if (isLoading()) return
e?.stopPropagation()
@@ -231,6 +270,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<div class="absolute top-4 left-6">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setShowUserModal(true)}
>
Users
</button>
</div>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<button
@@ -242,15 +290,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</button>
</div>
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={nomadArchLogo} alt="NomadArch logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">NomadArch</h1>
<p class="text-xs text-muted mb-1">Forked from OpenCode</p>
<Show when={activeUser()}>
{(user) => (
<p class="text-xs text-muted mb-1">
Active user: <span class="text-secondary font-medium">{user().name}</span>
</p>
)}
</Show>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
</div>
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
@@ -419,6 +475,104 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
<Show when={showUserModal()}>
<div class="modal-overlay">
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="modal-surface w-full max-w-lg p-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-primary">Users</h2>
<button class="selector-button selector-button-secondary" onClick={() => setShowUserModal(false)}>
Close
</button>
</div>
<Show when={userError()}>
{(msg) => <div class="text-sm text-red-400">{msg()}</div>}
</Show>
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-muted">Available</div>
<For each={users()}>
{(user) => (
<div class="flex items-center justify-between gap-3 px-3 py-2 rounded border border-base bg-surface-secondary">
<div class="text-sm text-primary">
{user.name}
<Show when={user.isGuest}>
<span class="ml-2 text-[10px] uppercase text-amber-400">Guest</span>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={!user.isGuest && loginTargetId() === user.id}>
<input
type="password"
placeholder="Password"
value={loginPassword()}
onInput={(event) => setLoginPassword(event.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
</Show>
<button
class="selector-button selector-button-primary"
onClick={() => {
if (user.isGuest) {
void handleLogin(user.id)
return
}
if (loginTargetId() !== user.id) {
setLoginTargetId(user.id)
setLoginPassword("")
return
}
void handleLogin(user.id)
}}
>
{activeUser()?.id === user.id ? "Active" : loginTargetId() === user.id ? "Unlock" : "Login"}
</button>
<button
class="selector-button selector-button-secondary"
onClick={() => void deleteUser(user.id)}
disabled={user.isGuest}
>
Remove
</button>
</div>
</div>
)}
</For>
</div>
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-muted">Create User</div>
<div class="flex flex-col gap-2">
<input
type="text"
placeholder="Name"
value={newUserName()}
onInput={(event) => setNewUserName(event.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
<input
type="password"
placeholder="Password"
value={newUserPassword()}
onInput={(event) => setNewUserPassword(event.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
<div class="flex gap-2">
<button class="selector-button selector-button-primary" onClick={() => void handleCreateUser()}>
Create
</button>
<button class="selector-button selector-button-secondary" onClick={() => void handleGuest()}>
Guest Mode
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
</>
)
}

View File

@@ -1,7 +1,10 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import { serverApi } from "../lib/api-client"
import { showToastNotification } from "../lib/notifications"
interface InstanceInfoProps {
instance: Instance
@@ -22,6 +25,68 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const env = environmentVariables()
return env ? Object.entries(env) : []
})
const [showExportDialog, setShowExportDialog] = createSignal(false)
const [showImportSourceDialog, setShowImportSourceDialog] = createSignal(false)
const [showImportDestinationDialog, setShowImportDestinationDialog] = createSignal(false)
const [importSourcePath, setImportSourcePath] = createSignal<string | null>(null)
const [includeConfig, setIncludeConfig] = createSignal(false)
const [isExporting, setIsExporting] = createSignal(false)
const [isImporting, setIsImporting] = createSignal(false)
const handleExport = async (destination: string) => {
if (isExporting()) return
setIsExporting(true)
try {
const response = await serverApi.exportWorkspace(currentInstance().id, {
destination,
includeConfig: includeConfig(),
})
showToastNotification({
title: "Workspace exported",
message: `Export saved to ${response.destination}`,
variant: "success",
duration: 7000,
})
} catch (error) {
showToastNotification({
title: "Export failed",
message: error instanceof Error ? error.message : "Unable to export workspace",
variant: "error",
duration: 8000,
})
} finally {
setIsExporting(false)
}
}
const handleImportDestination = async (destination: string) => {
const source = importSourcePath()
if (!source || isImporting()) return
setIsImporting(true)
try {
const response = await serverApi.importWorkspace({
source,
destination,
includeConfig: includeConfig(),
})
showToastNotification({
title: "Workspace imported",
message: `Imported workspace into ${response.path}`,
variant: "success",
duration: 7000,
})
} catch (error) {
showToastNotification({
title: "Import failed",
message: error instanceof Error ? error.message : "Unable to import workspace",
variant: "error",
duration: 8000,
})
} finally {
setIsImporting(false)
setImportSourcePath(null)
}
}
return (
<div class="panel">
@@ -116,6 +181,39 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
<div class="space-y-2">
<div class="text-xs font-medium text-muted uppercase tracking-wide">Workspace Export / Import</div>
<label class="flex items-center gap-2 text-xs text-secondary">
<input
type="checkbox"
checked={includeConfig()}
onChange={(event) => setIncludeConfig(event.currentTarget.checked)}
/>
Include user config (settings, keys)
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="button-secondary"
disabled={isExporting()}
onClick={() => setShowExportDialog(true)}
>
{isExporting() ? "Exporting..." : "Export Workspace"}
</button>
<button
type="button"
class="button-secondary"
disabled={isImporting()}
onClick={() => setShowImportSourceDialog(true)}
>
{isImporting() ? "Importing..." : "Import Workspace"}
</button>
</div>
<div class="text-[11px] text-muted">
Export creates a portable folder. Import restores the workspace into a chosen destination.
</div>
</div>
<Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1">
<div class="flex items-center gap-1.5">
@@ -155,6 +253,37 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</div>
</div>
<DirectoryBrowserDialog
open={showExportDialog()}
title="Export workspace to folder"
description="Choose a destination folder for the export package."
onClose={() => setShowExportDialog(false)}
onSelect={(destination) => {
setShowExportDialog(false)
void handleExport(destination)
}}
/>
<DirectoryBrowserDialog
open={showImportSourceDialog()}
title="Select export folder"
description="Pick the export folder that contains the workspace package."
onClose={() => setShowImportSourceDialog(false)}
onSelect={(source) => {
setShowImportSourceDialog(false)
setImportSourcePath(source)
setShowImportDestinationDialog(true)
}}
/>
<DirectoryBrowserDialog
open={showImportDestinationDialog()}
title="Select destination folder"
description="Choose the folder where the workspace should be imported."
onClose={() => setShowImportDestinationDialog(false)}
onSelect={(destination) => {
setShowImportDestinationDialog(false)
void handleImportDestination(destination)
}}
/>
</div>
)
}

View File

@@ -66,6 +66,7 @@ import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
import AdvancedSettingsModal from "../advanced-settings-modal"
import { showConfirmDialog } from "../../stores/alerts"
import {
getSoloState,
toggleAutonomous,
@@ -103,6 +104,7 @@ const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
@@ -150,6 +152,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [terminalOpen, setTerminalOpen] = createSignal(false)
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp", "plan"])
const [currentFile, setCurrentFile] = createSignal<FileNode | null>(null)
const [centerTab, setCenterTab] = createSignal<"code" | "preview">("code")
const [previewUrl, setPreviewUrl] = createSignal<string | null>(null)
const [isSoloOpen, setIsSoloOpen] = createSignal(true)
const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false)
const [selectedBinary, setSelectedBinary] = createSignal("opencode")
@@ -284,6 +288,25 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCleanup(() => window.removeEventListener("open-advanced-settings", handler))
})
onMount(() => {
if (typeof window === "undefined") return
const handler = async (event: Event) => {
const detail = (event as CustomEvent<{ url?: string; instanceId?: string }>).detail
if (!detail || detail.instanceId !== props.instance.id || !detail.url) return
setPreviewUrl(detail.url)
const confirmed = await showConfirmDialog(`Preview available at ${detail.url}. Open now?`, {
title: "Preview ready",
confirmLabel: "Open preview",
cancelLabel: "Later",
})
if (confirmed) {
setCenterTab("preview")
}
}
window.addEventListener(BUILD_PREVIEW_EVENT, handler)
onCleanup(() => window.removeEventListener(BUILD_PREVIEW_EVENT, handler))
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString())
@@ -449,6 +472,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
let sidebarActionId = 0
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null)
const [sidebarRequestedTab, setSidebarRequestedTab] = createSignal<string | null>(null)
const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
target.dispatchEvent(
@@ -499,6 +523,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
setPendingSidebarAction({ action, id: sidebarActionId++ })
if (action === "show-skills") {
setSidebarRequestedTab("skills")
}
if (!leftPinned() && !leftOpen()) {
setLeftOpen(true)
measureDrawerHost()
@@ -902,6 +929,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onToggleTerminal={() => setTerminalOpen((current) => !current)}
isTerminalOpen={terminalOpen()}
onOpenAdvancedSettings={() => setShowAdvancedSettings(true)}
requestedTab={sidebarRequestedTab()}
/>
)
@@ -1243,18 +1271,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button>
</div>
{/* SOLO Mode & Auto-Approval Toggles */}
{/* APEX PRO 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="Autonomous Mode (SOLO): Enable autonomous AI agent operations"
title="Autonomous Mode (APEX PRO): Enable autonomous AI agent operations"
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>
<span class="text-[9px] font-black uppercase tracking-tighter">APEX PRO</span>
</button>
<button
onClick={() => toggleAutoApproval(props.instance.id)}
@@ -1305,7 +1333,65 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
<div class="flex-1 flex overflow-hidden min-h-0">
<Show when={!isPhoneLayout()}>
<Editor file={currentFile()} />
<div class="flex-1 flex flex-col min-h-0 bg-[#0d0d0d]">
<div class="h-10 glass border-b border-white/5 flex items-center justify-between px-4 shrink-0">
<div class="flex items-center gap-2">
<button
type="button"
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${
centerTab() === "code"
? "bg-white/10 border-white/20 text-white"
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
}`}
onClick={() => setCenterTab("code")}
>
Code
</button>
<button
type="button"
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${
centerTab() === "preview"
? "bg-white/10 border-white/20 text-white"
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
}`}
onClick={() => setCenterTab("preview")}
disabled={!previewUrl()}
title={previewUrl() ? previewUrl() : "Run build to enable preview"}
>
Preview
</button>
</div>
<Show when={previewUrl()}>
{(url) => (
<div class="text-[10px] text-zinc-500 truncate max-w-[50%]" title={url()}>
{url()}
</div>
)}
</Show>
</div>
<Show when={centerTab() === "preview"} fallback={<Editor file={currentFile()} />}>
<Show
when={previewUrl()}
fallback={
<div class="flex-1 flex items-center justify-center text-zinc-500">
<div class="text-center">
<p>No preview available yet.</p>
<p class="text-sm mt-2 opacity-60">Run build to detect a preview URL.</p>
</div>
</div>
}
>
{(url) => (
<iframe
class="flex-1 w-full h-full border-none bg-black"
src={url()}
title="App Preview"
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-popups"
/>
)}
</Show>
</Show>
</div>
</Show>
<div

View File

@@ -1,4 +1,4 @@
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
import { Component, createSignal, For, Show, createEffect, createMemo, onCleanup } from "solid-js"
import {
Files,
Search,
@@ -18,6 +18,7 @@ import {
} from "lucide-solid"
import { serverApi } from "../../lib/api-client"
import InstanceServiceStatus from "../instance-service-status"
import McpManager from "../mcp-manager"
import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills"
import { getSessionSkills, setSessionSkills } from "../../stores/session-state"
@@ -41,6 +42,7 @@ interface SidebarProps {
onToggleTerminal?: () => void
isTerminalOpen?: boolean
onOpenAdvancedSettings?: () => void
requestedTab?: string | null
}
const getFileIcon = (fileName: string) => {
@@ -128,6 +130,7 @@ const FileTree: Component<{
export const Sidebar: Component<SidebarProps> = (props) => {
const [activeTab, setActiveTab] = createSignal("files")
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
const [lastRequestedTab, setLastRequestedTab] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("")
const [searchResults, setSearchResults] = createSignal<FileNode[]>([])
const [searchLoading, setSearchLoading] = createSignal(false)
@@ -141,20 +144,40 @@ export const Sidebar: Component<SidebarProps> = (props) => {
} | null>(null)
const [gitLoading, setGitLoading] = createSignal(false)
const [skillsFilter, setSkillsFilter] = createSignal("")
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
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)
}
const openExternal = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
const refreshRootFiles = async () => {
if (!props.instanceId) return
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)
}
}
createEffect(() => {
void refreshRootFiles()
})
createEffect(() => {
if (typeof window === "undefined") return
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ instanceId?: string }>).detail
if (!detail || detail.instanceId !== props.instanceId) return
void refreshRootFiles()
}
window.addEventListener(FILE_CHANGE_EVENT, handler)
onCleanup(() => window.removeEventListener(FILE_CHANGE_EVENT, handler))
})
createEffect(() => {
@@ -163,6 +186,13 @@ export const Sidebar: Component<SidebarProps> = (props) => {
}
})
createEffect(() => {
const nextTab = props.requestedTab ?? null
if (!nextTab || nextTab === lastRequestedTab()) return
setActiveTab(nextTab)
setLastRequestedTab(nextTab)
})
const filteredSkills = createMemo(() => {
const term = skillsFilter().trim().toLowerCase()
if (!term) return catalog()
@@ -410,10 +440,7 @@ export const Sidebar: Component<SidebarProps> = (props) => {
</div>
</Show>
<Show when={activeTab() === "mcp"}>
<div class="flex flex-col gap-3">
<div class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</div>
<InstanceServiceStatus sections={["mcp"]} />
</div>
<McpManager instanceId={props.instanceId} />
</Show>
<Show when={activeTab() === "skills"}>
<div class="flex flex-col gap-3">

View File

@@ -0,0 +1,501 @@
import { Dialog } from "@kobalte/core/dialog"
import { ChevronDown, ExternalLink, Plus, RefreshCw, Search, Settings } from "lucide-solid"
import { Component, For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
import InstanceServiceStatus from "./instance-service-status"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
type McpServerConfig = {
command?: string
args?: string[]
env?: Record<string, string>
}
type McpConfig = {
mcpServers?: Record<string, McpServerConfig>
}
type McpMarketplaceEntry = {
id: string
name: string
description: string
config: McpServerConfig
tags?: string[]
source?: string
}
interface McpManagerProps {
instanceId: string
}
const log = getLogger("mcp-manager")
const MCP_LINKER_RELEASES = "https://github.com/milisp/mcp-linker/releases"
const MCP_LINKER_MARKET = "https://github.com/milisp/mcp-linker"
const MARKETPLACE_ENTRIES: McpMarketplaceEntry[] = [
{
id: "sequential-thinking",
name: "Sequential Thinking",
description: "Step-by-step reasoning scratchpad for complex tasks.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-sequential-thinking"] },
tags: ["reasoning", "planning"],
source: "curated",
},
{
id: "desktop-commander",
name: "Desktop Commander",
description: "Control local desktop actions and automation.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-desktop-commander"] },
tags: ["automation", "local"],
source: "curated",
},
{
id: "web-reader",
name: "Web Reader",
description: "Fetch and summarize web pages with structured metadata.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-web-reader"] },
tags: ["web", "search"],
source: "curated",
},
{
id: "github",
name: "GitHub",
description: "Query GitHub repos, issues, and pull requests.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
tags: ["git", "productivity"],
source: "curated",
},
{
id: "postgres",
name: "PostgreSQL",
description: "Inspect PostgreSQL schemas and run safe queries.",
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-postgres"] },
tags: ["database"],
source: "curated",
},
]
const McpManager: Component<McpManagerProps> = (props) => {
const [config, setConfig] = createSignal<McpConfig>({ mcpServers: {} })
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [menuOpen, setMenuOpen] = createSignal(false)
const [showManual, setShowManual] = createSignal(false)
const [showMarketplace, setShowMarketplace] = createSignal(false)
const [marketplaceQuery, setMarketplaceQuery] = createSignal("")
const [marketplaceLoading, setMarketplaceLoading] = createSignal(false)
const [marketplaceEntries, setMarketplaceEntries] = createSignal<McpMarketplaceEntry[]>([])
const [rawMode, setRawMode] = createSignal(false)
const [serverName, setServerName] = createSignal("")
const [serverJson, setServerJson] = createSignal("")
const [saving, setSaving] = createSignal(false)
const metadataContext = useOptionalInstanceMetadataContext()
const metadata = createMemo(() => metadataContext?.metadata?.() ?? null)
const mcpStatus = createMemo(() => metadata()?.mcpStatus ?? {})
const servers = createMemo(() => Object.entries(config().mcpServers ?? {}))
const filteredMarketplace = createMemo(() => {
const combined = [...MARKETPLACE_ENTRIES, ...marketplaceEntries()]
const query = marketplaceQuery().trim().toLowerCase()
if (!query) return combined
return combined.filter((entry) => {
const haystack = `${entry.name} ${entry.description} ${entry.id} ${(entry.tags || []).join(" ")}`.toLowerCase()
return haystack.includes(query)
})
})
const loadConfig = async () => {
setIsLoading(true)
setError(null)
try {
const data = await serverApi.fetchWorkspaceMcpConfig(props.instanceId)
setConfig(data.config ?? { mcpServers: {} })
} catch (err) {
log.error("Failed to load MCP config", err)
setError("Failed to load MCP configuration.")
} finally {
setIsLoading(false)
}
}
createEffect(() => {
void loadConfig()
})
const openExternal = (url: string) => {
window.open(url, "_blank", "noopener")
}
const resetManualForm = () => {
setServerName("")
setServerJson("")
setRawMode(false)
}
const handleManualSave = async () => {
if (saving()) return
setSaving(true)
setError(null)
try {
const parsed = JSON.parse(serverJson() || "{}")
const nextConfig: McpConfig = { ...(config() ?? {}) }
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
if (rawMode()) {
if (!parsed || typeof parsed !== "object") {
throw new Error("Raw config must be a JSON object.")
}
setConfig(parsed as McpConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, parsed)
} else {
const name = serverName().trim()
if (!name) {
throw new Error("Server name is required.")
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Server config must be a JSON object.")
}
mcpServers[name] = parsed as McpServerConfig
nextConfig.mcpServers = mcpServers
setConfig(nextConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
}
resetManualForm()
setShowManual(false)
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid MCP configuration."
setError(message)
} finally {
setSaving(false)
}
}
const handleMarketplaceInstall = async (entry: McpMarketplaceEntry) => {
if (saving()) return
setSaving(true)
setError(null)
try {
const nextConfig: McpConfig = { ...(config() ?? {}) }
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
mcpServers[entry.id] = entry.config
nextConfig.mcpServers = mcpServers
setConfig(nextConfig)
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to install MCP server."
setError(message)
} finally {
setSaving(false)
}
}
const fetchNpmEntries = async (query: string, sourceLabel: string): Promise<McpMarketplaceEntry[]> => {
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=50`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${sourceLabel} MCP entries`)
}
const data = await response.json() as {
objects?: Array<{ package?: { name?: string; description?: string; keywords?: string[] } }>
}
const objects = Array.isArray(data.objects) ? data.objects : []
return objects
.map((entry) => entry.package)
.filter((pkg): pkg is { name: string; description?: string; keywords?: string[] } => Boolean(pkg?.name))
.map((pkg) => ({
id: pkg.name,
name: pkg.name.replace(/^@modelcontextprotocol\/server-/, ""),
description: pkg.description || "Community MCP server package",
config: { command: "npx", args: ["-y", pkg.name] },
tags: pkg.keywords,
source: sourceLabel,
}))
}
const loadMarketplace = async () => {
if (marketplaceLoading()) return
setMarketplaceLoading(true)
try {
const [official, community] = await Promise.allSettled([
fetchNpmEntries("@modelcontextprotocol/server", "npm:official"),
fetchNpmEntries("mcp server", "npm:community"),
])
const next: McpMarketplaceEntry[] = []
if (official.status === "fulfilled") next.push(...official.value)
if (community.status === "fulfilled") next.push(...community.value)
const deduped = new Map<string, McpMarketplaceEntry>()
for (const entry of next) {
if (!deduped.has(entry.id)) deduped.set(entry.id, entry)
}
setMarketplaceEntries(Array.from(deduped.values()))
} catch (err) {
log.error("Failed to load marketplace", err)
setError("Failed to load marketplace sources.")
} finally {
setMarketplaceLoading(false)
}
}
return (
<div class="mcp-manager">
<div class="mcp-manager-header">
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</span>
<button
onClick={loadConfig}
class="mcp-icon-button"
title="Refresh MCP servers"
>
<RefreshCw size={12} />
</button>
</div>
<div class="mcp-manager-actions">
<div class="relative">
<button
onClick={() => setMenuOpen((prev) => !prev)}
class="mcp-action-button"
title="Add MCP"
>
<Plus size={12} />
<span>Add</span>
<ChevronDown size={12} />
</button>
<Show when={menuOpen()}>
<div class="mcp-menu">
<button
class="mcp-menu-item"
onClick={() => {
setMenuOpen(false)
void loadMarketplace()
setShowMarketplace(true)
}}
>
Add from Marketplace
<ExternalLink size={12} />
</button>
<button
class="mcp-menu-item"
onClick={() => {
setMenuOpen(false)
resetManualForm()
setShowManual(true)
}}
>
Add Manually
</button>
</div>
</Show>
</div>
<button
onClick={() => openExternal(MCP_LINKER_RELEASES)}
class="mcp-link-button"
title="Install MCP Linker"
>
MCP Market
</button>
</div>
</div>
<Show when={error()}>
{(err) => <div class="text-[11px] text-amber-400">{err()}</div>}
</Show>
<Show
when={!isLoading() && servers().length > 0}
fallback={<div class="text-[11px] text-zinc-500 italic">{isLoading() ? "Loading MCP servers..." : "No MCP servers configured."}</div>}
>
<div class="mcp-server-list">
<For each={servers()}>
{([name, server]) => (
<div class="mcp-server-card">
<div class="mcp-server-row">
<div class="flex flex-col">
<span class="text-xs font-semibold text-zinc-100">{name}</span>
<span class="text-[11px] text-zinc-500 truncate">
{server.command ? `${server.command} ${(server.args ?? []).join(" ")}` : "Custom config"}
</span>
</div>
<div class="flex items-center gap-2">
<Show when={mcpStatus()?.[name]?.status}>
<span class="mcp-status-chip">
{mcpStatus()?.[name]?.status}
</span>
</Show>
<Show when={mcpStatus()?.[name]?.error}>
<span class="mcp-status-error" title={String(mcpStatus()?.[name]?.error)}>
error
</span>
</Show>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
<div class="mt-3">
<InstanceServiceStatus sections={["mcp"]} />
</div>
<Dialog open={showManual()} onOpenChange={setShowManual} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-2xl p-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<Dialog.Title class="text-sm font-semibold text-white">Configure MCP Server</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500">
Paste the MCP server config JSON. Use marketplace via MCP Linker for curated servers.
</Dialog.Description>
</div>
<button
class="text-xs px-2 py-1 rounded border border-white/10 text-zinc-400 hover:text-white"
onClick={() => setRawMode((prev) => !prev)}
>
{rawMode() ? "Server Mode" : "Raw Config (JSON)"}
</button>
</div>
<Show when={!rawMode()}>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Server Name
<input
value={serverName()}
onInput={(e) => setServerName(e.currentTarget.value)}
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
placeholder="example-server"
/>
</label>
</Show>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Config JSON
<textarea
value={serverJson()}
onInput={(e) => setServerJson(e.currentTarget.value)}
class="min-h-[200px] rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500/60"
placeholder='{"command":"npx","args":["-y","mcp-server-example"]}'
/>
</label>
<div class="flex items-center justify-end gap-2">
<button
onClick={() => {
resetManualForm()
setShowManual(false)
}}
class="px-3 py-1.5 text-xs rounded-md border border-white/10 text-zinc-300 hover:text-white"
>
Cancel
</button>
<button
onClick={handleManualSave}
disabled={saving()}
class="px-3 py-1.5 text-xs rounded-md bg-blue-500/20 border border-blue-500/40 text-blue-200 hover:text-white disabled:opacity-60"
>
{saving() ? "Saving..." : "Confirm"}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<Dialog open={showMarketplace()} onOpenChange={setShowMarketplace} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-3xl p-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<Dialog.Title class="text-sm font-semibold text-white">MCP Marketplace</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500">
Curated entries inspired by mcp-linker. Install writes to this workspace&apos;s .mcp.json.
</Dialog.Description>
</div>
<button
class="mcp-link-button"
onClick={() => openExternal(MCP_LINKER_MARKET)}
>
Open MCP Linker
</button>
</div>
<div class="mcp-market-search">
<Search size={14} class="text-zinc-500" />
<input
value={marketplaceQuery()}
onInput={(e) => setMarketplaceQuery(e.currentTarget.value)}
placeholder="Search MCP servers..."
class="mcp-market-input"
/>
</div>
<div class="mcp-market-list">
<Show
when={!marketplaceLoading()}
fallback={<div class="text-[11px] text-zinc-500 italic">Loading marketplace sources...</div>}
>
<For each={filteredMarketplace()}>
{(entry) => (
<div class="mcp-market-card">
<div class="mcp-market-card-info">
<div class="mcp-market-card-title">
{entry.name}
<Show when={entry.source}>
{(source) => <span class="mcp-market-source">{source()}</span>}
</Show>
</div>
<div class="mcp-market-card-desc">{entry.description}</div>
<Show when={entry.tags && entry.tags.length > 0}>
<div class="mcp-market-tags">
<For each={entry.tags}>
{(tag) => <span class="mcp-market-tag">{tag}</span>}
</For>
</div>
</Show>
</div>
<div class="mcp-market-card-actions">
<button
class="mcp-icon-button"
title="View config"
onClick={() => {
setShowManual(true)
setRawMode(false)
setServerName(entry.id)
setServerJson(JSON.stringify(entry.config, null, 2))
setShowMarketplace(false)
}}
>
<Settings size={14} />
</button>
<button
class="mcp-market-install"
onClick={() => handleMarketplaceInstall(entry)}
disabled={saving()}
>
<Plus size={12} />
Install
</button>
</div>
</div>
)}
</For>
</Show>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</div>
)
}
export default McpManager

View File

@@ -2,6 +2,8 @@ import { For, Show, createSignal } from "solid-js"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { compactSession } from "../stores/session-actions"
import { clearCompactionSuggestion } from "../stores/session-compaction"
import MessagePart from "./message-part"
interface MessageItemProps {
@@ -125,6 +127,27 @@ interface MessageItemProps {
return null
}
const isContextError = () => {
const info = props.messageInfo
if (!info) return false
const errorMessage = (info as any).error?.data?.message || (info as any).error?.message || ""
return (
errorMessage.includes("maximum context length") ||
errorMessage.includes("context_length_exceeded") ||
errorMessage.includes("token count exceeds") ||
errorMessage.includes("token limit")
)
}
const handleCompact = async () => {
try {
clearCompactionSuggestion(props.instanceId, props.sessionId)
await compactSession(props.instanceId, props.sessionId)
} catch (error) {
console.error("Failed to compact session:", error)
}
}
const hasContent = () => {
if (errorMessage() !== null) {
return true
@@ -138,6 +161,19 @@ interface MessageItemProps {
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
}
const isStreaming = () => {
return props.record.status === "streaming"
}
const currentTokenCount = () => {
if (!isStreaming()) return null
const textParts = props.parts.filter(p => p.type === "text")
return textParts.reduce((sum, p) => {
const text = (p as { text?: string }).text || ""
return sum + text.length
}, 0)
}
const handleRevert = () => {
if (props.onRevert && isUser()) {
props.onRevert(props.record.id)
@@ -185,7 +221,7 @@ interface MessageItemProps {
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
return modelID || "unknown"
}
const agentMeta = () => {
@@ -202,6 +238,20 @@ interface MessageItemProps {
return segments.join(" • ")
}
const modelBadge = () => {
if (isUser()) return null
const model = modelIdentifier()
if (!model) return null
return (
<span class="message-model-badge" title={`Model: ${model}`}>
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="text-xs font-medium text-zinc-400">{model}</span>
</span>
)
}
return (
<div class={containerClass()}>
@@ -259,6 +309,11 @@ interface MessageItemProps {
</Show>
</button>
</Show>
<Show when={modelBadge()}>
{(badge) => (
<span class="ml-2">{badge()}</span>
)}
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
@@ -266,13 +321,45 @@ interface MessageItemProps {
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={isStreaming()}>
<div class="message-streaming-indicator">
<span class="streaming-status">
<span class="streaming-pulse"></span>
<span class="streaming-text">Thinking</span>
</span>
<Show when={currentTokenCount() !== null}>
{(count) => (
<span class="streaming-tokens">
<span class="streaming-token-count">{count()}</span>
<span class="streaming-token-label">tokens</span>
</span>
)}
</Show>
</div>
</Show>
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div>
</Show>
<Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div>
<div class="message-error-block">
<div class="flex items-start gap-2">
<span> {errorMessage()}</span>
<Show when={isContextError()}>
<button
onClick={handleCompact}
class="compact-button"
title="Compact session to reduce context usage"
>
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16l6-6-6 6M4 20l6-6 6-6" />
</svg>
Compact
</button>
</Show>
</div>
</div>
</Show>
<Show when={isGenerating()}>

View File

@@ -7,6 +7,9 @@ import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import { getSessionStatus } from "../stores/session-status"
import { compactSession } from "../stores/session-actions"
import { clearCompactionSuggestion, getCompactionSuggestion } from "../stores/session-compaction"
const SCROLL_SCOPE = "session"
const SCROLL_SENTINEL_MARGIN_PX = 48
@@ -51,6 +54,10 @@ export default function MessageSection(props: MessageSectionProps) {
contextAvailableTokens: null,
},
)
const isCompacting = createMemo(() => getSessionStatus(props.instanceId, props.sessionId) === "compacting")
const compactionSuggestion = createMemo(() =>
getCompactionSuggestion(props.instanceId, props.sessionId),
)
const tokenStats = createMemo(() => {
const usage = usageSnapshot()
@@ -747,6 +754,30 @@ export default function MessageSection(props: MessageSectionProps) {
<div class="message-stream-container">
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
<div class="message-stream-shell" ref={setShellElement}>
<Show when={isCompacting()}>
<div class="compaction-banner" role="status" aria-live="polite">
<span class="spinner compaction-banner-spinner" aria-hidden="true" />
<span>Compacting context</span>
</div>
</Show>
<Show when={!isCompacting() && compactionSuggestion()}>
<div class="compaction-suggestion" role="status" aria-live="polite">
<div class="compaction-suggestion-text">
<span class="compaction-suggestion-label">Compact suggested</span>
<span class="compaction-suggestion-message">{compactionSuggestion()!.reason}</span>
</div>
<button
type="button"
class="compaction-suggestion-action"
onClick={() => {
clearCompactionSuggestion(props.instanceId, props.sessionId)
void compactSession(props.instanceId, props.sessionId)
}}
>
Compact now
</button>
</div>
</Show>
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
<Show when={!props.loading && messageIds().length === 0}>

View File

@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session"
import { getLogger } from "../lib/logger"
import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("session")
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
@@ -40,7 +41,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
const readOfflineModels = () => {
if (typeof window === "undefined") return new Set<string>()
try {
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const raw = window.localStorage.getItem(getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY))
const parsed = raw ? JSON.parse(raw) : []
return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : [])
} catch {
@@ -57,7 +58,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
if (typeof window === "undefined") return
const handleCustom = () => refreshOfflineModels()
const handleStorage = (event: StorageEvent) => {
if (event.key === OPENCODE_ZEN_OFFLINE_STORAGE_KEY) {
if (event.key === getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)) {
refreshOfflineModels()
}
}

View File

@@ -1169,6 +1169,12 @@ export default function PromptInput(props: PromptInputProps) {
</div>
<div class="prompt-input-actions">
<Show when={props.isSessionBusy}>
<div class="thinking-indicator" aria-live="polite">
<span class="thinking-spinner" aria-hidden="true" />
<span>Thinking</span>
</div>
</Show>
<button
type="button"
class="stop-button"

View File

@@ -2,6 +2,8 @@ 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'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
interface OllamaCloudConfig {
enabled: boolean
@@ -12,9 +14,11 @@ interface OllamaCloudConfig {
interface OllamaCloudModelsResponse {
models: Array<{
name: string
size: string
digest: string
modified_at: string
model?: string
size?: string | number
digest?: string
modified_at?: string
details?: any
}>
}
@@ -25,14 +29,20 @@ const OllamaCloudSettings: Component = () => {
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
const [models, setModels] = createSignal<string[]>([])
const [isLoadingModels, setIsLoadingModels] = createSignal(false)
const [hasStoredApiKey, setHasStoredApiKey] = createSignal(false)
// Load config on mount
onMount(async () => {
try {
const response = await fetch('http://localhost:6149/api/ollama/config')
const response = await fetch('/api/ollama/config')
if (response.ok) {
const data = await response.json()
setConfig(data.config)
const maskedKey = typeof data.config?.apiKey === "string" && /^\*+$/.test(data.config.apiKey)
setHasStoredApiKey(Boolean(data.config?.apiKey) && maskedKey)
setConfig({
...data.config,
apiKey: maskedKey ? "" : data.config?.apiKey,
})
}
} catch (error) {
console.error('Failed to load Ollama config:', error)
@@ -47,10 +57,15 @@ const OllamaCloudSettings: Component = () => {
const saveConfig = async () => {
setIsLoading(true)
try {
const response = await fetch('http://localhost:6149/api/ollama/config', {
const payload: OllamaCloudConfig = { ...config() }
if (!payload.apiKey && hasStoredApiKey()) {
delete payload.apiKey
}
const response = await fetch('/api/ollama/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config())
body: JSON.stringify(payload)
})
if (response.ok) {
@@ -58,6 +73,16 @@ const OllamaCloudSettings: Component = () => {
duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" />
})
// Refresh providers for all instances so models appear in selector
const instanceList = Array.from(instances().values())
for (const instance of instanceList) {
try {
await fetchProviders(instance.id)
} catch (error) {
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
}
}
} else {
throw new Error('Failed to save config')
}
@@ -74,22 +99,22 @@ const OllamaCloudSettings: Component = () => {
const testConnection = async () => {
setIsTesting(true)
setConnectionStatus('testing')
try {
const response = await fetch('http://localhost:6149/api/ollama/test', {
const response = await fetch('/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 {
@@ -115,13 +140,32 @@ const OllamaCloudSettings: Component = () => {
const loadModels = async () => {
setIsLoadingModels(true)
try {
const response = await fetch('http://localhost:6149/api/ollama/models/cloud')
const response = await fetch('/api/ollama/models')
if (response.ok) {
const data: OllamaCloudModelsResponse = await response.json()
setModels(data.models.map(model => model.name))
const data = await response.json()
// Handle different response formats
if (data.models && Array.isArray(data.models)) {
setModels(data.models.map((model: any) => model.name || model.model || 'unknown'))
if (data.models.length > 0) {
toast.success(`Loaded ${data.models.length} models`, { duration: 2000 })
}
} else {
console.warn('Unexpected models response format:', data)
setModels([])
}
} else {
const errorData = await response.json().catch(() => ({}))
toast.error(`Failed to load models: ${errorData.error || response.statusText}`, {
duration: 5000,
icon: <XCircle class="w-4 h-4 text-red-500" />
})
}
} catch (error) {
console.error('Failed to load models:', error)
toast.error('Failed to load models - network error', {
duration: 5000,
icon: <XCircle class="w-4 h-4 text-red-500" />
})
} finally {
setIsLoadingModels(false)
}
@@ -164,12 +208,13 @@ const OllamaCloudSettings: Component = () => {
<label class="block font-medium mb-2">API Key</label>
<input
type="password"
placeholder="Enter your Ollama Cloud API key"
placeholder={hasStoredApiKey() ? "API key stored (leave empty to keep)" : "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}
/>
<p class="text-xs text-gray-500 mt-1">Get your API key from <a href="https://ollama.com/settings/keys" target="_blank" class="text-blue-500 underline">ollama.com/settings/keys</a></p>
</div>
{/* Endpoint */}
@@ -183,6 +228,7 @@ const OllamaCloudSettings: Component = () => {
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}
/>
<p class="text-xs text-gray-500 mt-1">Default: https://ollama.com (for local Ollama use: http://localhost:11434)</p>
</div>
{/* Test Connection */}
@@ -196,7 +242,7 @@ const OllamaCloudSettings: Component = () => {
{getStatusIcon()}
{isTesting() ? 'Testing...' : 'Test Connection'}
</Button>
<Show when={connectionStatus() === 'connected'}>
<span class="text-green-600 text-sm">Connected successfully</span>
</Show>
@@ -208,8 +254,8 @@ const OllamaCloudSettings: Component = () => {
{/* 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">
<label class="block font-medium mb-2">Available Models</label>
<div class="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
{models().map(model => (
<div class="p-3 border border-gray-200 rounded-md bg-gray-50">
<code class="text-sm font-mono">{model}</code>
@@ -236,4 +282,4 @@ const OllamaCloudSettings: Component = () => {
)
}
export default OllamaCloudSettings
export default OllamaCloudSettings

View File

@@ -3,6 +3,8 @@ 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'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
interface QwenUser {
id: string
@@ -17,7 +19,7 @@ interface QwenUser {
}
const QwenCodeSettings: Component = () => {
const { isAuthenticated, user, isLoading, signIn, signOut, createApiClient } = useQwenOAuth()
const { isAuthenticated, user, isLoading, signIn, signOut, tokenInfo } = useQwenOAuth()
const [isSigningOut, setIsSigningOut] = createSignal(false)
const handleSignIn = async () => {
@@ -27,6 +29,13 @@ const QwenCodeSettings: Component = () => {
duration: 3000,
icon: <CheckCircle class="w-4 h-4 text-green-500" />
})
for (const instance of instances().values()) {
try {
await fetchProviders(instance.id)
} catch (error) {
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
}
}
} catch (error) {
toast.error('Failed to authenticate with Qwen Code', {
duration: 5000,
@@ -59,6 +68,32 @@ const QwenCodeSettings: Component = () => {
return `${user.limits.requests_per_day} requests/day, ${user.limits.requests_per_minute}/min`
}
const formatTokenExpiry = () => {
const token = tokenInfo()
if (!token) return "Token not available"
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000
const remainingMs = Math.max(0, expiresAt - Date.now())
const remainingMin = Math.floor(remainingMs / 60000)
return `${remainingMin} min remaining`
}
const tokenStatus = () => {
const token = tokenInfo()
if (!token) return "Unknown"
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000
return Date.now() < expiresAt ? "Active" : "Expired"
}
const tokenId = () => {
const token = tokenInfo()
if (!token?.access_token) return "Unavailable"
const value = token.access_token
if (value.length <= 12) return value
return `${value.slice(0, 6)}...${value.slice(-4)}`
}
return (
<div class="space-y-6 p-6">
<div class="flex items-center gap-2 mb-4">
@@ -128,6 +163,16 @@ const QwenCodeSettings: Component = () => {
{formatRemainingRequests(user()!)}
</span>
</Show>
<span class="text-xs text-green-600 dark:text-green-400">
{formatTokenExpiry()}
</span>
</div>
<div class="flex items-center gap-2 mt-2 text-xs text-green-700 dark:text-green-300">
<span class="font-semibold">Token ID:</span>
<span class="font-mono">{tokenId()}</span>
<span class="px-2 py-0.5 rounded-full bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200">
{tokenStatus()}
</span>
</div>
</div>
</div>
@@ -188,4 +233,4 @@ const QwenCodeSettings: Component = () => {
)
}
export default QwenCodeSettings
export default QwenCodeSettings

View File

@@ -137,7 +137,7 @@ const ZAISettings: Component = () => {
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">GLM Coding Plan</h3>
<p class="text-sm text-blue-800 dark:text-blue-200">
Z.AI provides access to Claude models through their GLM Coding Plan. Get your API key from the{' '}
Z.AI provides access to GLM-4.7, GLM-4.6, GLM-4.5, and other GLM models through their PaaS/v4 API. Get your API key from the{' '}
<a
href="https://z.ai/manage-apikey/apikey-list"
target="_blank"
@@ -182,12 +182,11 @@ const ZAISettings: Component = () => {
</p>
</div>
{/* Endpoint */}
<div>
<label class="block font-medium mb-2">Endpoint</label>
<input
type="text"
placeholder="https://api.z.ai/api/anthropic"
placeholder="https://api.z.ai/api/paas/v4"
value={config().endpoint || ''}
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-800"

View File

@@ -29,6 +29,7 @@ const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
function makeRenderCacheKey(
toolCallId?: string | null,
@@ -304,6 +305,7 @@ export default function ToolCall(props: ToolCallProps) {
let toolCallRootRef: HTMLDivElement | undefined
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let lastFileEventKey = ""
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
@@ -493,6 +495,19 @@ export default function ToolCall(props: ToolCallProps) {
})
})
createEffect(() => {
const state = toolState()
if (!state || state.status !== "completed") return
const tool = toolName()
if (!["write", "edit", "patch"].includes(tool)) return
const key = `${toolCallIdentifier()}:${tool}:${state.status}`
if (key === lastFileEventKey) return
lastFileEventKey = key
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId: props.instanceId } }))
}
})
createEffect(() => {
const activeKey = activePermissionKey()
if (!activeKey) return

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

View File

@@ -6,6 +6,7 @@
@import './styles/markdown.css';
@import './styles/tabs.css';
@import './styles/antigravity.css';
@import './styles/responsive.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -18,6 +19,7 @@
html,
body {
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
@@ -27,12 +29,18 @@ body {
-moz-osx-font-smoothing: grayscale;
background-color: var(--surface-base);
color: var(--text-primary);
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
#root {
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
background-color: var(--surface-base);
overflow: hidden;
}
@@ -61,6 +69,5 @@ body {

View File

@@ -0,0 +1,390 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
validateStructuredSummary,
validateCompactionEvent,
validateCompactionResult,
sanitizeStructuredSummary,
type StructuredSummary,
type CompactionEvent,
type CompactionResult,
} from "../compaction-schema.js"
describe("compaction schema", () => {
describe("validateStructuredSummary", () => {
it("validates tierA summary", () => {
const summary: StructuredSummary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Created API endpoint", "Added error handling"],
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
current_state: "API endpoint implemented with error handling",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(result.success)
assert.equal(result.data.summary_type, "tierA_short")
})
it("validates tierB summary", () => {
const summary: StructuredSummary = {
timestamp: new Date().toISOString(),
summary_type: "tierB_detailed",
what_was_done: ["Created API endpoint", "Added error handling", "Wrote unit tests"],
files: [
{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" },
{ path: "src/api.test.ts", notes: "Test file", decision_id: "decision-2" },
],
current_state: "API endpoint implemented with error handling and full test coverage",
key_decisions: [
{
id: "decision-1",
decision: "Use Fastify for performance",
rationale: "Fastify provides better performance than Express",
actor: "agent",
},
],
next_steps: ["Add authentication", "Implement rate limiting"],
blockers: [],
artifacts: [],
tags: ["api", "fastify"],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1500,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(result.success)
assert.equal(result.data.summary_type, "tierB_detailed")
assert.ok(result.data.key_decisions)
assert.equal(result.data.key_decisions.length, 1)
})
it("rejects invalid timestamp", () => {
const summary = {
timestamp: "invalid-date",
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
assert.ok(result.errors.length > 0)
})
it("rejects empty what_was_done array", () => {
const summary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: [],
files: [],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
assert.ok(result.errors.some((e) => e.includes("what_was_done")))
})
it("rejects empty current_state", () => {
const summary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
assert.ok(result.errors.some((e) => e.includes("current_state")))
})
it("rejects invalid actor in key_decisions", () => {
const summary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "API endpoint implemented",
key_decisions: [
{
id: "decision-1",
decision: "Use Fastify",
rationale: "Performance",
actor: "invalid" as any,
},
],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const result = validateStructuredSummary(summary)
assert.ok(!result.success)
})
})
describe("validateCompactionEvent", () => {
it("validates user-triggered compaction", () => {
const event: CompactionEvent = {
event_id: "comp_1234567890",
timestamp: new Date().toISOString(),
actor: "user",
trigger_reason: "manual",
token_before: 10000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(result.success)
assert.equal(result.data.actor, "user")
})
it("validates auto-triggered compaction", () => {
const event: CompactionEvent = {
event_id: "auto_1234567890",
timestamp: new Date().toISOString(),
actor: "auto",
trigger_reason: "overflow",
token_before: 15000,
token_after: 5000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.07,
}
const result = validateCompactionEvent(event)
assert.ok(result.success)
assert.equal(result.data.actor, "auto")
assert.equal(result.data.trigger_reason, "overflow")
})
it("rejects negative token values", () => {
const event = {
event_id: "comp_1234567890",
timestamp: new Date().toISOString(),
actor: "user" as const,
trigger_reason: "manual" as const,
token_before: -1000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(!result.success)
})
it("rejects empty event_id", () => {
const event = {
event_id: "",
timestamp: new Date().toISOString(),
actor: "user" as const,
trigger_reason: "manual" as const,
token_before: 10000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(!result.success)
})
it("rejects invalid actor", () => {
const event = {
event_id: "comp_1234567890",
timestamp: new Date().toISOString(),
actor: "invalid" as any,
trigger_reason: "manual" as const,
token_before: 10000,
token_after: 3000,
model_used: "claude-3.5-sonnet",
cost_estimate: 0.05,
}
const result = validateCompactionEvent(event)
assert.ok(!result.success)
})
})
describe("validateCompactionResult", () => {
it("validates successful compaction", () => {
const result: CompactionResult = {
success: true,
mode: "compact",
human_summary: "Compacted 100 messages",
detailed_summary: {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Compacted 100 messages"],
files: [],
current_state: "Session compacted",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
},
token_before: 10000,
token_after: 3000,
token_reduction_pct: 70,
}
const validation = validateCompactionResult(result)
assert.ok(validation.success)
})
it("validates failed compaction", () => {
const result: CompactionResult = {
success: false,
mode: "compact",
human_summary: "Compaction failed",
token_before: 10000,
token_after: 10000,
token_reduction_pct: 0,
}
const validation = validateCompactionResult(result)
assert.ok(validation.success)
assert.equal(validation.data.success, false)
})
it("rejects invalid token reduction percentage", () => {
const result = {
success: true,
mode: "compact" as const,
human_summary: "Compacted 100 messages",
token_before: 10000,
token_after: 3000,
token_reduction_pct: 150,
}
const validation = validateCompactionResult(result)
assert.ok(!validation.success)
})
it("rejects negative token reduction percentage", () => {
const result = {
success: true,
mode: "compact" as const,
human_summary: "Compacted 100 messages",
token_before: 10000,
token_after: 3000,
token_reduction_pct: -10,
}
const validation = validateCompactionResult(result)
assert.ok(!validation.success)
})
})
describe("sanitizeStructuredSummary", () => {
it("sanitizes summary by removing extra fields", () => {
const dirtySummary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short" as const,
what_was_done: ["Created API endpoint"],
files: [],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
extraField: "should be removed",
anotherExtra: { nested: "data" },
}
const clean = sanitizeStructuredSummary(dirtySummary)
assert.ok(clean)
assert.ok(!("extraField" in clean))
assert.ok(!("anotherExtra" in clean))
assert.equal(clean?.summary_type, "tierA_short")
})
it("preserves all valid fields", () => {
const summary: StructuredSummary = {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Created API endpoint"],
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
current_state: "API endpoint implemented",
key_decisions: [],
next_steps: ["Add tests"],
blockers: [],
artifacts: [],
tags: ["api"],
provenance: {
model: "claude-3.5-sonnet",
token_count: 1000,
redactions: [],
},
aggressive: false,
}
const clean = sanitizeStructuredSummary(summary)
assert.ok(clean)
assert.equal(clean?.what_was_done.length, 1)
assert.ok(clean?.files)
assert.equal(clean.files.length, 1)
assert.ok(clean?.next_steps)
assert.equal(clean.next_steps.length, 1)
assert.ok(clean?.tags)
assert.equal(clean.tags.length, 1)
})
})
})

View File

@@ -0,0 +1,158 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { redactSecrets, hasSecrets, redactObject } from "../secrets-detector.js"
describe("secrets detector", () => {
describe("redactSecrets", () => {
it("redacts API keys", () => {
const content = "My API key is sk-1234567890abcdef"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("sk-1234567890abcdef"))
})
it("redacts AWS access keys", () => {
const content = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("AKIAIOSFODNN7EXAMPLE"))
})
it("redacts bearer tokens", () => {
const content = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
})
it("redacts GitHub tokens", () => {
const content = "github_pat_11AAAAAAAAAAAAAAAAAAAAAA"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("github_pat_11AAAAAAAAAAAAAAAAAAAAAA"))
})
it("redacts npm tokens", () => {
const content = "npm_1234567890abcdef1234567890abcdef1234"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(!result.clean.includes("npm_1234567890abcdef1234567890abcdef1234"))
})
it("preserves non-sensitive content", () => {
const content = "This is a normal message without any secrets"
const result = redactSecrets(content, "test")
assert.equal(result.clean, content)
assert.equal(result.redactions.length, 0)
})
it("handles empty content", () => {
const content = ""
const result = redactSecrets(content, "test")
assert.equal(result.clean, "")
assert.equal(result.redactions.length, 0)
})
it("provides redaction reasons", () => {
const content = "API key: sk-1234567890abcdef"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.ok(result.redactions[0].reason.length > 0)
})
it("tracks redaction paths", () => {
const content = "sk-1234567890abcdef"
const result = redactSecrets(content, "test")
assert.ok(result.redactions.length > 0)
assert.equal(typeof result.redactions[0].path, "string")
assert.ok(result.redactions[0].path.length > 0)
})
})
describe("hasSecrets", () => {
it("detects API keys", () => {
const content = "sk-1234567890abcdef"
assert.ok(hasSecrets(content))
})
it("detects bearer tokens", () => {
const content = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
assert.ok(hasSecrets(content))
})
it("returns false for normal content", () => {
const content = "This is a normal message"
assert.ok(!hasSecrets(content))
})
it("returns false for empty content", () => {
const content = ""
assert.ok(!hasSecrets(content))
})
})
describe("redactObject", () => {
it("redacts secrets in nested objects", () => {
const obj = {
apiKey: "sk-1234567890abcdef",
nested: {
token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
},
}
const result = redactObject(obj, "test")
assert.ok(!result.apiKey.includes("sk-1234567890abcdef"))
assert.ok(!result.nested.token.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
})
it("redacts secrets in arrays", () => {
const obj = {
messages: [
{ content: "Use sk-1234567890abcdef" },
{ content: "Normal message" },
],
}
const result = redactObject(obj, "test")
assert.ok(!result.messages[0].content.includes("sk-1234567890abcdef"))
assert.equal(result.messages[1].content, "Normal message")
})
it("preserves non-sensitive fields", () => {
const obj = {
name: "John Doe",
age: 30,
message: "Hello world",
}
const result = redactObject(obj, "test")
assert.equal(result.name, "John Doe")
assert.equal(result.age, 30)
assert.equal(result.message, "Hello world")
})
it("handles null and undefined values", () => {
const obj = {
value: null,
undefined: undefined,
message: "sk-1234567890abcdef",
}
const result = redactObject(obj, "test")
assert.equal(result.value, null)
assert.equal(result.undefined, undefined)
assert.ok(!result.message.includes("sk-1234567890abcdef"))
})
it("preserves object structure", () => {
const obj = {
level1: {
level2: {
level3: {
secret: "sk-1234567890abcdef",
},
},
},
}
const result = redactObject(obj, "test")
assert.ok(result.level1.level2.level3.secret)
assert.ok(!result.level1.level2.level3.secret.includes("sk-1234567890abcdef"))
})
})
})

View File

@@ -19,6 +19,13 @@ import type {
WorkspaceEventPayload,
WorkspaceEventType,
WorkspaceGitStatus,
WorkspaceExportRequest,
WorkspaceExportResponse,
WorkspaceImportRequest,
WorkspaceImportResponse,
WorkspaceMcpConfigRequest,
WorkspaceMcpConfigResponse,
PortAvailabilityResponse,
} from "../../../server/src/api-types"
import { getLogger } from "./logger"
@@ -158,6 +165,27 @@ export const serverApi = {
fetchWorkspaceGitStatus(id: string): Promise<WorkspaceGitStatus> {
return request<WorkspaceGitStatus>(`/api/workspaces/${encodeURIComponent(id)}/git/status`)
},
exportWorkspace(id: string, payload: WorkspaceExportRequest): Promise<WorkspaceExportResponse> {
return request<WorkspaceExportResponse>(`/api/workspaces/${encodeURIComponent(id)}/export`, {
method: "POST",
body: JSON.stringify(payload),
})
},
importWorkspace(payload: WorkspaceImportRequest): Promise<WorkspaceImportResponse> {
return request<WorkspaceImportResponse>("/api/workspaces/import", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchWorkspaceMcpConfig(id: string): Promise<WorkspaceMcpConfigResponse> {
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`)
},
updateWorkspaceMcpConfig(id: string, config: WorkspaceMcpConfigRequest["config"]): Promise<WorkspaceMcpConfigResponse> {
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`, {
method: "PUT",
body: JSON.stringify({ config }),
})
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
@@ -241,6 +269,9 @@ export const serverApi = {
const params = new URLSearchParams({ id })
return request<SkillDetail>(`/api/skills/detail?${params.toString()}`)
},
fetchAvailablePort(): Promise<PortAvailabilityResponse> {
return request<PortAvailabilityResponse>("/api/ports/available")
},
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -0,0 +1,168 @@
import { z } from "zod"
import { getLogger } from "./logger.js"
const log = getLogger("compaction-schema")
export const SecretRedactionSchema = z.object({
path: z.string(),
reason: z.string(),
})
export const ProvenanceSchema = z.object({
model: z.string().min(1, "Model name is required"),
token_count: z.number().int().nonnegative(),
redactions: z.array(SecretRedactionSchema),
})
export const KeyDecisionSchema = z.object({
id: z.string().min(1, "Decision ID is required"),
decision: z.string().min(1, "Decision is required"),
rationale: z.string().min(1, "Rationale is required"),
actor: z.enum(["agent", "user"], { errorMap: () => ({ message: "Actor must be 'agent' or 'user'" }) }),
})
export const ArtifactSchema = z.object({
type: z.string().min(1, "Artifact type is required"),
uri: z.string().min(1, "Artifact URI is required"),
notes: z.string(),
})
export const FileReferenceSchema = z.object({
path: z.string().min(1, "File path is required"),
notes: z.string(),
decision_id: z.string().min(1, "Decision ID is required"),
})
export const StructuredSummarySchema = z.object({
timestamp: z.string().datetime(),
summary_type: z.enum(["tierA_short", "tierB_detailed"]),
what_was_done: z.array(z.string()).min(1, "At least one 'what_was_done' entry is required"),
files: z.array(FileReferenceSchema).optional(),
current_state: z.string().min(1, "Current state is required"),
key_decisions: z.array(KeyDecisionSchema).optional(),
next_steps: z.array(z.string()).optional(),
blockers: z.array(z.string()).optional(),
artifacts: z.array(ArtifactSchema).optional(),
tags: z.array(z.string()).optional(),
provenance: ProvenanceSchema,
aggressive: z.boolean(),
})
export const CompactionEventSchema = z.object({
event_id: z.string().min(1, "Event ID is required"),
timestamp: z.string().datetime(),
actor: z.enum(["user", "auto"], { errorMap: () => ({ message: "Actor must be 'user' or 'auto'" }) }),
trigger_reason: z.enum(["overflow", "scheduled", "manual"]),
token_before: z.number().int().nonnegative(),
token_after: z.number().int().nonnegative(),
model_used: z.string().min(1, "Model name is required"),
cost_estimate: z.number().nonnegative(),
snapshot_id: z.string().optional(),
})
export const CompactionConfigSchema = z.object({
autoCompactEnabled: z.boolean(),
autoCompactThreshold: z.number().int().min(1).max(100),
compactPreserveWindow: z.number().int().positive(),
pruneReclaimThreshold: z.number().int().positive(),
userPreference: z.enum(["auto", "ask", "never"]),
undoRetentionWindow: z.number().int().positive(),
recentMessagesToKeep: z.number().int().positive().optional(),
systemMessagesToKeep: z.number().int().positive().optional(),
incrementalChunkSize: z.number().int().positive().optional(),
// ADK-style sliding window settings
compactionInterval: z.number().int().positive().optional(),
overlapSize: z.number().int().nonnegative().optional(),
enableAiSummarization: z.boolean().optional(),
summaryMaxTokens: z.number().int().positive().optional(),
preserveFileOperations: z.boolean().optional(),
preserveDecisions: z.boolean().optional(),
})
export const CompactionResultSchema = z.object({
success: z.boolean(),
mode: z.enum(["prune", "compact"]),
human_summary: z.string().min(1, "Human summary is required"),
detailed_summary: StructuredSummarySchema.optional(),
token_before: z.number().int().nonnegative(),
token_after: z.number().int().nonnegative(),
token_reduction_pct: z.number().int().min(0).max(100),
compaction_event: CompactionEventSchema.optional(),
preview: z.string().optional(),
})
export type SecretRedaction = z.infer<typeof SecretRedactionSchema>
export type Provenance = z.infer<typeof ProvenanceSchema>
export type KeyDecision = z.infer<typeof KeyDecisionSchema>
export type Artifact = z.infer<typeof ArtifactSchema>
export type FileReference = z.infer<typeof FileReferenceSchema>
export type StructuredSummary = z.infer<typeof StructuredSummarySchema>
export type CompactionEvent = z.infer<typeof CompactionEventSchema>
export type CompactionConfig = z.infer<typeof CompactionConfigSchema>
export type CompactionResult = z.infer<typeof CompactionResultSchema>
export function validateStructuredSummary(data: unknown): { success: true; data: StructuredSummary } | { success: false; errors: string[] } {
const result = StructuredSummarySchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function validateCompactionEvent(data: unknown): { success: true; data: CompactionEvent } | { success: false; errors: string[] } {
const result = CompactionEventSchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function validateCompactionResult(data: unknown): { success: true; data: CompactionResult } | { success: false; errors: string[] } {
const result = CompactionResultSchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function validateCompactionConfig(data: unknown): { success: true; data: CompactionConfig } | { success: false; errors: string[] } {
const result = CompactionConfigSchema.safeParse(data)
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
return { success: false, errors }
}
return { success: true, data: result.data }
}
export function sanitizeStructuredSummary(input: unknown): StructuredSummary | null {
const result = validateStructuredSummary(input)
if (!result.success) {
log.warn("Invalid structured summary, using fallback", { errors: result.errors })
return null
}
return result.data
}
export function createDefaultStructuredSummary(aggressive: boolean = false): StructuredSummary {
return {
timestamp: new Date().toISOString(),
summary_type: "tierA_short",
what_was_done: ["Session compaction completed"],
files: [],
current_state: "Session context has been compacted",
key_decisions: [],
next_steps: [],
blockers: [],
artifacts: [],
tags: [],
provenance: {
model: "system",
token_count: 0,
redactions: [],
},
aggressive,
}
}

View File

@@ -11,7 +11,7 @@ import {
getSessions,
setActiveSession,
} from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction"
import { compactSession } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
@@ -235,21 +235,9 @@ export function useCommands(options: UseCommandsOptions) {
const sessionId = activeSessionIdForInstance()
if (!instance || !instance.client || !sessionId || sessionId === "info") return
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return
try {
setSessionCompactionState(instance.id, sessionId, true)
await instance.client.session.summarize({
path: { id: sessionId },
body: {
providerID: session.model.providerId,
modelID: session.model.modelId,
},
})
await compactSession(instance.id, sessionId)
} catch (error) {
setSessionCompactionState(instance.id, sessionId, false)
log.error("Failed to compact session", error)
const message = error instanceof Error ? error.message : "Failed to compact session"
showAlertDialog(`Compact failed: ${message}`, {

View File

@@ -0,0 +1,286 @@
/**
* Qwen OAuth Chat Service
* Routes chat requests through the Qwen API using OAuth tokens
* Based on the qwen-code implementation from QwenLM/qwen-code
*/
import { getUserScopedKey } from "../user-storage"
const QWEN_TOKEN_STORAGE_KEY = 'qwen_oauth_token'
const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com'
export interface QwenToken {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
resource_url?: string
created_at: number
}
export interface QwenChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface QwenChatRequest {
model: string
messages: QwenChatMessage[]
stream?: boolean
temperature?: number
max_tokens?: number
}
export interface QwenChatResponse {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
message: {
role: string
content: string
}
finish_reason: string | null
}>
usage?: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
}
export interface QwenStreamChunk {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
delta: {
role?: string
content?: string
}
finish_reason: string | null
}>
}
/**
* Get stored Qwen OAuth token from localStorage
*/
export function getStoredQwenToken(): QwenToken | null {
try {
const stored = localStorage.getItem(getUserScopedKey(QWEN_TOKEN_STORAGE_KEY))
return stored ? JSON.parse(stored) : null
} catch {
return null
}
}
/**
* Check if Qwen OAuth token is valid and not expired
*/
export function isQwenTokenValid(token: QwenToken | null): boolean {
if (!token || !token.access_token) return false
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000 // 5 min buffer
return Date.now() < expiresAt
}
/**
* Get the API endpoint URL for Qwen
* Uses resource_url from token if available, otherwise falls back to default
*/
export function getQwenEndpoint(token: QwenToken | null): string {
const baseEndpoint = token?.resource_url || DEFAULT_QWEN_ENDPOINT
// Normalize URL: add protocol if missing
const normalizedUrl = baseEndpoint.startsWith('http')
? baseEndpoint
: `https://${baseEndpoint}`
// Ensure /v1 suffix for OpenAI-compatible API
return normalizedUrl.endsWith('/v1')
? normalizedUrl
: `${normalizedUrl}/v1`
}
/**
* Send a chat completion request to Qwen API
*/
export async function sendQwenChatRequest(
request: QwenChatRequest
): Promise<QwenChatResponse> {
const token = getStoredQwenToken()
if (!isQwenTokenValid(token)) {
throw new Error('Qwen OAuth token is invalid or expired. Please re-authenticate.')
}
const endpoint = getQwenEndpoint(token)
const url = `${endpoint}/chat/completions`
console.log(`[QwenChat] Sending request to: ${url}`)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token!.access_token}`,
'Accept': 'application/json'
},
body: JSON.stringify({
model: request.model || 'qwen-coder-plus-latest',
messages: request.messages,
stream: false,
temperature: request.temperature,
max_tokens: request.max_tokens
})
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[QwenChat] Request failed: ${response.status}`, errorText)
// Check for auth errors that require re-authentication
if (response.status === 401 || response.status === 403) {
throw new Error('Qwen OAuth token expired. Please re-authenticate using /auth.')
}
throw new Error(`Qwen chat request failed: ${response.status} - ${errorText}`)
}
return await response.json()
}
/**
* Send a streaming chat completion request to Qwen API
*/
export async function* sendQwenChatStreamRequest(
request: QwenChatRequest
): AsyncGenerator<QwenStreamChunk> {
const token = getStoredQwenToken()
if (!isQwenTokenValid(token)) {
throw new Error('Qwen OAuth token is invalid or expired. Please re-authenticate.')
}
const endpoint = getQwenEndpoint(token)
const url = `${endpoint}/chat/completions`
console.log(`[QwenChat] Sending streaming request to: ${url}`)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token!.access_token}`,
'Accept': 'text/event-stream'
},
body: JSON.stringify({
model: request.model || 'qwen-coder-plus-latest',
messages: request.messages,
stream: true,
temperature: request.temperature,
max_tokens: request.max_tokens
})
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[QwenChat] Stream request failed: ${response.status}`, errorText)
throw new Error(`Qwen chat request failed: ${response.status} - ${errorText}`)
}
if (!response.body) {
throw new Error('Response body is missing')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// Keep the last incomplete line in buffer
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed === 'data: [DONE]') {
continue
}
if (trimmed.startsWith('data: ')) {
try {
const data = JSON.parse(trimmed.slice(6))
yield data as QwenStreamChunk
} catch (e) {
console.warn('[QwenChat] Failed to parse SSE chunk:', trimmed)
}
}
}
}
} finally {
reader.releaseLock()
}
}
/**
* Get available Qwen models
*/
export async function getQwenModels(): Promise<{ id: string; name: string }[]> {
const token = getStoredQwenToken()
if (!isQwenTokenValid(token)) {
return []
}
const endpoint = getQwenEndpoint(token)
const url = `${endpoint}/models`
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token!.access_token}`,
'Accept': 'application/json'
}
})
if (!response.ok) {
console.warn(`[QwenChat] Failed to fetch models: ${response.status}`)
return getDefaultQwenModels()
}
const data = await response.json()
return (data.data || []).map((model: any) => ({
id: model.id,
name: model.id
}))
} catch (error) {
console.warn('[QwenChat] Error fetching models:', error)
return getDefaultQwenModels()
}
}
/**
* Get default Qwen models when API call fails
*/
function getDefaultQwenModels(): { id: string; name: string }[] {
return [
{ id: 'qwen-coder-plus-latest', name: 'Qwen Coder Plus' },
{ id: 'qwen-turbo-latest', name: 'Qwen Turbo' },
{ id: 'qwen-plus-latest', name: 'Qwen Plus' },
{ id: 'qwen-max-latest', name: 'Qwen Max' }
]
}

View File

@@ -4,8 +4,8 @@
*/
import { nanoid } from 'nanoid'
import type { AxiosInstance, AxiosResponse } from 'axios'
import { createSignal, onMount } from 'solid-js'
import { getUserScopedKey } from "../user-storage"
// Configuration schema
export interface QwenConfig {
@@ -13,6 +13,7 @@ export interface QwenConfig {
redirectUri?: string
scope?: string
baseUrl?: string
apiBaseUrl?: string
}
export interface QwenAuthToken {
@@ -22,6 +23,7 @@ export interface QwenAuthToken {
refresh_token?: string
scope?: string
created_at: number
resource_url?: string
}
export interface QwenUser {
@@ -43,82 +45,77 @@ export interface QwenOAuthState {
redirect_uri: string
}
function toBase64Url(bytes: Uint8Array): string {
let binary = ''
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export class QwenOAuthManager {
private config: Required<QwenConfig>
private tokenStorageKey = 'qwen_oauth_token'
private userStorageKey = 'qwen_user_info'
private config: { clientId: string; redirectUri: string; scope: string; baseUrl: string }
private tokenStorageKey = getUserScopedKey('qwen_oauth_token')
private userStorageKey = getUserScopedKey('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'
scope: config.scope || 'openid profile email model.completion',
baseUrl: config.apiBaseUrl || config.baseUrl || ''
}
}
/**
* Generate OAuth URL for authentication
* Request device authorization for Qwen OAuth
*/
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'
async requestDeviceAuthorization(codeChallenge: string): Promise<{
device_code: string
user_code: string
verification_uri: string
verification_uri_complete: string
expires_in: number
}> {
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/device`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
})
const authUrl = `${this.config.baseUrl}/oauth/authorize?${params.toString()}`
return {
url: authUrl,
state
if (!response.ok) {
const message = await response.text()
throw new Error(`Device authorization failed: ${message}`)
}
return await response.json()
}
/**
* Exchange authorization code for access token
* Poll device token endpoint
*/
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
})
async pollDeviceToken(deviceCode: string, codeVerifier: string): Promise<any> {
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
device_code: deviceCode,
code_verifier: codeVerifier
})
})
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
const rawText = await response.text()
try {
return JSON.parse(rawText)
} catch {
throw new Error(`Token poll failed: ${rawText}`)
}
}
@@ -132,14 +129,12 @@ export class QwenOAuthManager {
}
try {
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Type': 'application/json',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.config.clientId,
body: JSON.stringify({
refresh_token: currentToken.refresh_token
})
})
@@ -151,7 +146,7 @@ export class QwenOAuthManager {
const tokenData = await response.json()
const token = this.parseTokenResponse(tokenData)
this.storeToken(token)
return token
} catch (error) {
// If refresh fails, clear stored token
@@ -169,17 +164,28 @@ export class QwenOAuthManager {
throw new Error('Not authenticated')
}
const response = await fetch(`${this.config.baseUrl}/api/user`, {
headers: {
'Authorization': `Bearer ${token.access_token}`
try {
const response = await fetch(`/api/qwen/user`, {
headers: {
'Authorization': `Bearer ${token.access_token}`
}
})
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`)
}
const data = await response.json()
return data.user || data
} catch {
return {
id: 'qwen-oauth',
username: 'Qwen OAuth',
tier: 'Free',
limits: {
requests_per_day: 0,
requests_per_minute: 0
}
}
})
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`)
}
return await response.json()
}
/**
@@ -191,11 +197,7 @@ export class QwenOAuthManager {
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) {
if (this.isTokenExpired(token)) {
try {
return await this.refreshToken()
} catch (error) {
@@ -207,37 +209,6 @@ export class QwenOAuthManager {
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
*/
@@ -250,8 +221,9 @@ export class QwenOAuthManager {
* Check if user is authenticated
*/
isAuthenticated(): boolean {
const token = this.getValidToken()
return token !== null
const token = this.getStoredToken()
if (!token) return false
return !this.isTokenExpired(token)
}
/**
@@ -269,7 +241,7 @@ export class QwenOAuthManager {
/**
* Store user info
*/
private storeUserInfo(user: QwenUser): void {
storeUserInfo(user: QwenUser): void {
localStorage.setItem(this.userStorageKey, JSON.stringify(user))
}
@@ -297,7 +269,7 @@ export class QwenOAuthManager {
// Store state temporarily
sessionStorage.setItem(`qwen_oauth_${state}`, JSON.stringify(oauthState))
return oauthState
}
@@ -323,38 +295,34 @@ export class QwenOAuthManager {
/**
* Generate code verifier for PKCE
*/
private generateCodeVerifier(): string {
generateCodeVerifier(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return Array.from(array, byte => String.fromCharCode(byte)).join('')
return toBase64Url(array)
}
/**
* Generate code challenge for PKCE
*/
private async generateCodeChallenge(verifier: string): Promise<string> {
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(/=+$/, '')
return toBase64Url(new Uint8Array(digest))
}
/**
* Parse token response
*/
private parseTokenResponse(data: any): QwenAuthToken {
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()
resource_url: data.resource_url,
created_at: Math.floor(Date.now() / 1000)
}
return token
@@ -363,7 +331,7 @@ export class QwenOAuthManager {
/**
* Store token
*/
private storeToken(token: QwenAuthToken): void {
storeToken(token: QwenAuthToken): void {
localStorage.setItem(this.tokenStorageKey, JSON.stringify(token))
}
@@ -379,6 +347,16 @@ export class QwenOAuthManager {
}
}
getTokenInfo(): QwenAuthToken | null {
return this.getStoredToken()
}
private isTokenExpired(token: QwenAuthToken): boolean {
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000
return Date.now() >= expiresAt
}
/**
* Clear token
*/
@@ -393,70 +371,82 @@ export function useQwenOAuth(config?: QwenConfig) {
const [isAuthenticated, setIsAuthenticated] = createSignal(false)
const [user, setUser] = createSignal<QwenUser | null>(null)
const [isLoading, setIsLoading] = createSignal(false)
const [tokenInfo, setTokenInfo] = createSignal<QwenAuthToken | null>(null)
// Check authentication status on mount
onMount(() => {
const manager = authManager()
if (manager.isAuthenticated()) {
manager.getValidToken().then((token) => {
if (!token) return
setIsAuthenticated(true)
setTokenInfo(manager.getTokenInfo())
const userInfo = manager.getUserInfo()
if (userInfo) {
setUser(userInfo)
}
}
}).catch(() => {
setIsAuthenticated(false)
})
})
const signIn = async () => {
setIsLoading(true)
try {
const manager = authManager()
const { url, state } = await manager.generateAuthUrl()
// Open popup window for OAuth
const codeVerifier = manager.generateCodeVerifier()
const codeChallenge = await manager.generateCodeChallenge(codeVerifier)
const deviceAuth = await manager.requestDeviceAuthorization(codeChallenge)
const popup = window.open(
url,
deviceAuth.verification_uri_complete,
'qwen-oauth',
'width=500,height=600,scrollbars=yes,resizable=yes'
)
if (!popup) {
throw new Error('Failed to open OAuth popup')
window.alert(
`Open this URL to authenticate: ${deviceAuth.verification_uri_complete}\n\nUser code: ${deviceAuth.user_code}`,
)
}
// Listen for popup close
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed)
setIsLoading(false)
}
}, 1000)
const expiresAt = Date.now() + deviceAuth.expires_in * 1000
let pollInterval = 2000
// 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)
while (Date.now() < expiresAt) {
const tokenData = await manager.pollDeviceToken(deviceAuth.device_code, codeVerifier)
if (tokenData?.access_token) {
const token = manager.parseTokenResponse(tokenData)
manager.storeToken(token)
setTokenInfo(manager.getTokenInfo())
const userInfo = await manager.fetchUserInfo()
setUser(userInfo)
if (userInfo) {
manager.storeUserInfo(userInfo)
setUser(userInfo)
} else {
setUser(null)
}
setIsAuthenticated(true)
setIsLoading(false)
popup.close()
} else if (event.data.type === 'QWEN_OAUTH_ERROR') {
setIsLoading(false)
popup.close()
popup?.close()
return
}
if (tokenData?.error === 'authorization_pending') {
await new Promise((resolve) => setTimeout(resolve, pollInterval))
continue
}
if (tokenData?.error === 'slow_down') {
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), 10000)
await new Promise((resolve) => setTimeout(resolve, pollInterval))
continue
}
throw new Error(tokenData?.error_description || tokenData?.error || 'OAuth failed')
}
window.addEventListener('message', messageListener)
// Cleanup
setTimeout(() => {
clearInterval(checkClosed)
window.removeEventListener('message', messageListener)
setIsLoading(false)
}, 300000) // 5 minute timeout
throw new Error('OAuth timed out')
} catch (error) {
setIsLoading(false)
@@ -469,18 +459,15 @@ export function useQwenOAuth(config?: QwenConfig) {
manager.signOut()
setIsAuthenticated(false)
setUser(null)
}
const createApiClient = () => {
return authManager().createApiClient()
setTokenInfo(null)
}
return {
isAuthenticated: () => isAuthenticated(),
user: () => user(),
isLoading: () => isLoading(),
tokenInfo: () => tokenInfo(),
signIn,
signOut,
createApiClient
signOut
}
}
}

View File

@@ -0,0 +1,225 @@
import { getLogger } from "./logger.js"
const log = getLogger("secrets-detector")
export interface SecretMatch {
type: string
value: string
start: number
end: number
reason: string
}
export interface RedactionResult {
clean: string
redactions: { path: string; reason: string }[]
}
export interface SecretPattern {
name: string
pattern: RegExp
reason: string
}
const SECRET_PATTERNS: SecretPattern[] = [
{
name: "api_key",
pattern: /['"]?api[_-]?key['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{20,})['"]?/gi,
reason: "API key detected",
},
{
name: "bearer_token",
pattern: /bearer\s+([a-zA-Z0-9_-]{30,})/gi,
reason: "Bearer token detected",
},
{
name: "jwt_token",
pattern: /eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g,
reason: "JWT token detected",
},
{
name: "aws_access_key",
pattern: /AKIA[0-9A-Z]{16}/g,
reason: "AWS access key detected",
},
{
name: "aws_secret_key",
pattern: /['"]?aws[_-]?secret[_-]?access[_-]?key['"]?\s*[:=]\s*['"]?([a-zA-Z0-9/+]{40})['"]?/gi,
reason: "AWS secret key detected",
},
{
name: "private_key",
pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(RSA\s+)?PRIVATE\s+KEY-----/gi,
reason: "Private key detected",
},
{
name: "password",
pattern: /['"]?(password|passwd|pwd)['"]?\s*[:=]\s*['"]?([^'\s"]{8,})['"]?/gi,
reason: "Password field detected",
},
{
name: "secret",
pattern: /['"]?(secret|api[_-]?secret)['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{16,})['"]?/gi,
reason: "Secret field detected",
},
{
name: "token",
pattern: /['"]?(token|access[_-]?token|auth[_-]?token)['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{30,})['"]?/gi,
reason: "Auth token detected",
},
{
name: "github_token",
pattern: /gh[pous]_[a-zA-Z0-9]{36}/g,
reason: "GitHub token detected",
},
{
name: "openai_key",
pattern: /sk-[a-zA-Z0-9]{48}/g,
reason: "OpenAI API key detected",
},
{
name: "database_url",
pattern: /(mongodb|postgres|mysql|redis):\/\/[^\s'"]+/gi,
reason: "Database connection URL detected",
},
{
name: "credit_card",
pattern: /\b(?:\d[ -]*?){13,16}\b/g,
reason: "Potential credit card number detected",
},
{
name: "email",
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
reason: "Email address detected",
},
{
name: "ip_address",
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
reason: "IP address detected",
},
]
const REPLACEMENT_PLACEHOLDER = "[REDACTED]"
function detectSecrets(content: string): SecretMatch[] {
const matches: SecretMatch[] = []
for (const pattern of SECRET_PATTERNS) {
let match
const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags)
while ((match = regex.exec(content)) !== null) {
matches.push({
type: pattern.name,
value: match[0],
start: match.index,
end: match.index + match[0].length,
reason: pattern.reason,
})
}
}
return matches.sort((a, b) => a.start - b.start)
}
function mergeOverlappingMatches(matches: SecretMatch[]): SecretMatch[] {
if (matches.length === 0) return []
const merged: SecretMatch[] = [matches[0]]
for (let i = 1; i < matches.length; i++) {
const current = matches[i]
const last = merged[merged.length - 1]
if (current.start <= last.end) {
last.end = Math.max(last.end, current.end)
if (!last.reason.includes(current.reason)) {
last.reason += ` | ${current.reason}`
}
} else {
merged.push(current)
}
}
return merged
}
export function redactSecrets(content: string, contextPath: string = "unknown"): RedactionResult {
if (!content || typeof content !== "string") {
return { clean: content, redactions: [] }
}
const rawMatches = detectSecrets(content)
const mergedMatches = mergeOverlappingMatches(rawMatches)
if (mergedMatches.length === 0) {
return { clean: content, redactions: [] }
}
let result = ""
let lastIndex = 0
const redactions: { path: string; reason: string }[] = []
for (const match of mergedMatches) {
result += content.slice(lastIndex, match.start)
result += REPLACEMENT_PLACEHOLDER
lastIndex = match.end
redactions.push({
path: `${contextPath}[${match.start}:${match.end}]`,
reason: match.reason,
})
}
result += content.slice(lastIndex)
log.info("Redacted secrets", { contextPath, count: redactions.length, types: mergedMatches.map((m) => m.type) })
return { clean: result, redactions }
}
export function hasSecrets(content: string): boolean {
if (!content || typeof content !== "string") {
return false
}
return SECRET_PATTERNS.some((pattern) => pattern.pattern.test(content))
}
export function redactObject(obj: any, contextPath: string = "root"): any {
if (obj === null || obj === undefined) {
return obj
}
if (typeof obj === "string") {
const result = redactSecrets(obj, contextPath)
return result.clean
}
if (Array.isArray(obj)) {
return obj.map((item, index) => redactObject(item, `${contextPath}[${index}]`))
}
if (typeof obj === "object") {
const result: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = redactObject(obj[key], `${contextPath}.${key}`)
}
}
return result
}
return obj
}
export function getSecretsReport(content: string): { total: number; byType: Record<string, number> } {
const matches = detectSecrets(content)
const byType: Record<string, number> = {}
for (const match of matches) {
byType[match.type] = (byType[match.type] || 0) + 1
}
return { total: matches.length, byType }
}

View File

@@ -1,4 +1,8 @@
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
export type SessionSidebarRequestAction =
| "focus-agent-selector"
| "focus-model-selector"
| "show-session-list"
| "show-skills"
export interface SessionSidebarRequestDetail {
instanceId: string

View File

@@ -0,0 +1,7 @@
import { activeUser } from "../stores/users"
export function getUserScopedKey(baseKey: string): string {
const userId = activeUser()?.id
if (!userId) return baseKey
return `${baseKey}:${userId}`
}

View File

@@ -1,32 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<style>
:root {
color-scheme: dark;
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NomadArch</title>
<style>
:root {
color-scheme: dark;
}
html,
body {
background-color: #1a1a1a;
color: #e0e0e0;
}
</style>
<script>
; (function () {
try {
document.documentElement.setAttribute('data-theme', 'dark')
} catch (error) {
const rawConsole = globalThis?.["console"]
rawConsole?.warn?.('Failed to apply initial theme', error)
}
html,
body {
background-color: #1a1a1a;
color: #e0e0e0;
}
</style>
<script>
;(function () {
try {
document.documentElement.setAttribute('data-theme', 'dark')
} catch (error) {
const rawConsole = globalThis?.["console"]
rawConsole?.warn?.('Failed to apply initial theme', error)
}
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -29,12 +29,14 @@ button {
max-width: 520px;
width: 100%;
text-align: center;
animation: fadeIn 0.4s ease-out;
}
.loading-logo {
width: 180px;
height: auto;
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
animation: logoPulse 3s ease-in-out infinite;
}
.loading-heading {
@@ -54,6 +56,7 @@ button {
margin: 0;
font-size: 1rem;
color: var(--text-muted, #aeb3c4);
animation: fadeIn 0.3s ease-out;
}
.loading-card {
@@ -64,7 +67,13 @@ button {
border-radius: 18px;
background: rgba(13, 16, 24, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(108, 227, 255, 0.05);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.loading-card:hover {
border-color: rgba(108, 227, 255, 0.15);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(108, 227, 255, 0.1);
}
.loading-row {
@@ -81,7 +90,8 @@ button {
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
animation: spin 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
box-shadow: 0 0 10px rgba(108, 227, 255, 0.3);
}
.phrase-controls {
@@ -93,12 +103,29 @@ button {
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
padding: 4px 12px;
border-radius: 6px;
transition: all 0.2s ease;
}
.phrase-controls button:hover {
background: rgba(143, 181, 255, 0.1);
transform: translateY(-1px);
}
.phrase-controls button:active {
transform: translateY(0);
}
.loading-error {
margin-top: 12px;
padding: 12px 16px;
background: rgba(255, 94, 109, 0.1);
border: 1px solid rgba(255, 94, 109, 0.2);
border-radius: 8px;
color: #ff9ea9;
font-size: 0.95rem;
font-size: 0.9rem;
animation: fadeIn 0.3s ease-out;
}
@keyframes spin {
@@ -109,3 +136,23 @@ button {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes logoPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}

View File

@@ -1,6 +1,6 @@
import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web"
import iconUrl from "../../images/CodeNomad-Icon.png"
import iconUrl from "../../images/NomadArch-Icon.png"
import { runtimeEnv, isTauriHost } from "../../lib/runtime-env"
import "../../index.css"
import "./loading.css"
@@ -202,7 +202,7 @@ function LoadingApp() {
<img src={iconUrl} alt="NomadArch" class="loading-logo" width="180" height="180" />
<div class="loading-heading">
<h1 class="loading-title">NomadArch 1.0</h1>
<p class="loading-subtitle" style={{ fontSize: '14px', color: '#666', marginTop: '4px' }}>A fork of OpenCode</p>
<p class="loading-subtitle" style={{ "font-size": '14px', "color": '#666', "margin-top": '4px' }}>A fork of OpenCode</p>
<Show when={status()}>{(statusText) => <p class="loading-status">{statusText()}</p>}</Show>
</div>
<div class="loading-card">

View File

@@ -0,0 +1,273 @@
import assert from "node:assert/strict"
import { beforeEach, describe, it, mock } from "node:test"
import type { CompactionResult } from "../session-compaction.js"
import {
getCompactionConfig,
updateCompactionConfig,
undoCompaction,
rehydrateSession,
checkAndTriggerAutoCompact,
setSessionCompactionState,
getSessionCompactionState,
estimateTokenReduction,
executeCompactionWrapper,
} from "../session-compaction.js"
import type { CompactionEvent, StructuredSummary } from "../../lib/compaction-schema.js"
const MOCK_INSTANCE_ID = "test-instance-123"
const MOCK_SESSION_ID = "test-session-456"
const MOCK_MESSAGE_ID = "msg-789"
function createMockMessage(id: string, content: string = "Test message"): any {
return {
id,
sessionId: MOCK_SESSION_ID,
role: "user",
content,
status: "complete",
parts: [{ id: `part-${id}`, type: "text", text: content, sessionID: MOCK_SESSION_ID, messageID: id }],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
function createMockUsage(tokens: number = 10000): any {
return {
totalInputTokens: Math.floor(tokens * 0.7),
totalOutputTokens: Math.floor(tokens * 0.2),
totalReasoningTokens: Math.floor(tokens * 0.1),
}
}
describe("session compaction", () => {
beforeEach(() => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "auto",
undoRetentionWindow: 5,
})
})
describe("getCompactionConfig", () => {
it("returns default config", () => {
const config = getCompactionConfig()
assert.equal(typeof config.autoCompactEnabled, "boolean")
assert.equal(typeof config.autoCompactThreshold, "number")
assert.equal(typeof config.compactPreserveWindow, "number")
assert.equal(typeof config.pruneReclaimThreshold, "number")
assert.equal(typeof config.userPreference, "string")
assert.equal(typeof config.undoRetentionWindow, "number")
})
it("allows config updates", () => {
updateCompactionConfig({
autoCompactEnabled: false,
autoCompactThreshold: 80,
compactPreserveWindow: 4000,
pruneReclaimThreshold: 8000,
userPreference: "ask",
undoRetentionWindow: 10,
})
const config = getCompactionConfig()
assert.equal(config.autoCompactEnabled, false)
assert.equal(config.autoCompactThreshold, 80)
assert.equal(config.userPreference, "ask")
assert.equal(config.undoRetentionWindow, 10)
})
})
describe("setSessionCompactionState and getSessionCompactionState", () => {
it("tracks compaction state for sessions", () => {
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(isCompacting)
})
it("returns undefined for unknown sessions", () => {
const isCompacting = getSessionCompactionState("unknown-instance", "unknown-session")
assert.equal(isCompacting, undefined)
})
it("clears compaction state", () => {
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, false)
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!isCompacting)
})
})
describe("estimateTokenReduction", () => {
it("calculates correct percentage reduction", () => {
const reduction = estimateTokenReduction(10000, 3000)
assert.equal(reduction, 70)
})
it("returns 0 when no reduction", () => {
const reduction = estimateTokenReduction(10000, 10000)
assert.equal(reduction, 0)
})
it("handles zero tokens", () => {
const reduction = estimateTokenReduction(0, 0)
assert.equal(reduction, 0)
})
it("caps at 100%", () => {
const reduction = estimateTokenReduction(10000, -5000)
assert.equal(reduction, 100)
})
it("handles small values", () => {
const reduction = estimateTokenReduction(100, 50)
assert.equal(reduction, 50)
})
})
describe("executeCompactionWrapper", () => {
it("compacts session successfully", async () => {
const mockStore = {
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getSessionUsage: () => createMockUsage(10000),
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
assert.ok(result.success)
assert.equal(result.mode, "compact")
assert.ok(result.token_before > 0)
assert.ok(result.token_after >= 0)
assert.ok(result.token_reduction_pct >= 0)
assert.ok(result.human_summary.length > 0)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("handles missing instance", async () => {
const getInstanceMock = mock.fn(() => null)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
assert.ok(!result.success)
assert.equal(result.human_summary, "Instance not found")
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("handles prune mode", async () => {
const mockStore = {
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getSessionUsage: () => createMockUsage(10000),
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "prune")
assert.ok(result.success)
assert.equal(result.mode, "prune")
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
})
describe("checkAndTriggerAutoCompact", () => {
it("does not trigger when user preference is never", async () => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "never",
undoRetentionWindow: 5,
})
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!shouldCompact)
})
it("returns false when no overflow", async () => {
const mockStore = {
getSessionUsage: () => createMockUsage(50000),
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(!shouldCompact)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
it("triggers auto-compact when enabled", async () => {
updateCompactionConfig({
autoCompactEnabled: true,
autoCompactThreshold: 90,
compactPreserveWindow: 5000,
pruneReclaimThreshold: 10000,
userPreference: "auto",
undoRetentionWindow: 5,
})
const mockStore = {
getSessionUsage: () => createMockUsage(120000),
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
getMessage: (id: string) => createMockMessage(id, "Test content"),
upsertMessage: () => {},
setMessageInfo: () => {},
}
const getInstanceMock = mock.fn(() => mockStore)
const originalBus = (globalThis as any).messageStoreBus
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
assert.ok(shouldCompact)
getInstanceMock.mock.restore()
if (originalBus) {
;(globalThis as any).messageStoreBus = originalBus
} else {
delete (globalThis as any).messageStoreBus
}
})
})
})

View File

@@ -10,6 +10,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
agentModelSelections: {},
sessionTasks: {},
sessionSkills: {},
customAgents: [],
}
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
@@ -24,6 +25,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
sessionTasks: { ...(source.sessionTasks ?? {}) },
sessionSkills: { ...(source.sessionSkills ?? {}) },
customAgents: Array.isArray(source.customAgents) ? [...source.customAgents] : [],
}
}

View File

@@ -87,3 +87,20 @@ class MessageStoreBus {
}
export const messageStoreBus = new MessageStoreBus()
export async function archiveMessages(instanceId: string, sessionId: string, keepLastN: number = 2): Promise<void> {
const store = messageStoreBus.getInstance(instanceId)
if (!store) return
const messageIds = store.getSessionMessageIds(sessionId)
if (messageIds.length <= keepLastN) return
const messagesToArchive = messageIds.slice(0, -keepLastN)
const archiveId = `archived_${sessionId}_${Date.now()}`
for (const messageId of messagesToArchive) {
store.setMessageInfo(messageId, { archived: true } as any)
}
log.info("Archived messages", { instanceId, sessionId, count: messagesToArchive.length, archiveId })
}

View File

@@ -41,7 +41,7 @@ function ensureVisibilityEffect() {
if (!activeToast || activeToastVersion !== release.version) {
dismissActiveToast()
activeToast = showToastNotification({
title: `CodeNomad ${release.version}`,
title: `NomadArch ${release.version}`,
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
variant: "info",
duration: Number.POSITIVE_INFINITY,

View File

@@ -9,12 +9,20 @@ import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus"
import { buildRecordDisplayData } from "./message-v2/record-display-cache"
import { getLogger } from "../lib/logger"
import { executeCompactionWrapper, getSessionCompactionState, setSessionCompactionState, type CompactionResult } from "./session-compaction"
import {
executeCompactionWrapper,
getSessionCompactionState,
setSessionCompactionState,
setCompactionSuggestion,
clearCompactionSuggestion,
type CompactionResult,
} from "./session-compaction"
import { createSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { showConfirmDialog } from "./alerts"
import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
import { getUserScopedKey } from "../lib/user-storage"
import { loadSkillDetails } from "./skills"
import { serverApi } from "../lib/api-client"
const log = getLogger("actions")
@@ -28,16 +36,18 @@ const COMPACTION_ATTEMPT_TTL_MS = 60_000
const COMPACTION_SUMMARY_MAX_CHARS = 4000
const STREAM_TIMEOUT_MS = 120_000
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
function markOpencodeZenModelOffline(modelId: string): void {
if (typeof window === "undefined" || !modelId) return
try {
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const key = getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
const raw = window.localStorage.getItem(key)
const parsed = raw ? JSON.parse(raw) : []
const list = Array.isArray(parsed) ? parsed : []
if (!list.includes(modelId)) {
list.push(modelId)
window.localStorage.setItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY, JSON.stringify(list))
window.localStorage.setItem(key, JSON.stringify(list))
window.dispatchEvent(
new CustomEvent("opencode-zen-offline-models", { detail: { modelId } }),
)
@@ -209,21 +219,11 @@ async function checkTokenBudgetBeforeSend(
warningThreshold,
})
const confirmed = await showConfirmDialog(
`Context limit approaching (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens). Compact now to continue?`,
{
title: "Token Budget Warning",
confirmLabel: "Compact",
cancelLabel: "Continue Anyway",
},
setCompactionSuggestion(
instanceId,
sessionId,
`Context usage is high (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens).`,
)
if (confirmed) {
setTokenWarningState(instanceId, sessionId, true)
await compactSession(instanceId, sessionId)
return false
}
setTokenWarningState(instanceId, sessionId, true)
return true
}
@@ -233,6 +233,8 @@ async function checkTokenBudgetBeforeSend(
type ExternalChatMessage = { role: "user" | "assistant" | "system"; content: string }
const MAX_ATTACHMENT_CHARS = 8000
function shouldForceEnglish(prompt: string): boolean {
const text = prompt.trim()
if (!text) return false
@@ -256,6 +258,13 @@ function buildLanguageSystemInstruction(prompt: string): string | undefined {
return "Respond in English unless the user explicitly requests another language."
}
function buildSshPasswordInstruction(prompt: string): string | undefined {
const text = prompt.toLowerCase()
if (!text.includes("ssh")) return undefined
if (!text.includes("password") && !text.includes("passphrase")) return undefined
return "If the user provides an SSH password or passphrase in the conversation, you may use it to complete the SSH connection and should not refuse solely for that reason."
}
function clampText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value
return `${value.slice(0, Math.max(0, maxChars - 3))}...`
@@ -290,18 +299,46 @@ async function mergeSystemInstructions(
Promise.resolve(buildLanguageSystemInstruction(prompt)),
buildSkillsSystemInstruction(instanceId, sessionId),
])
if (languageSystem && skillsSystem) {
return `${languageSystem}\n\n${skillsSystem}`
}
return languageSystem || skillsSystem
const sshInstruction = buildSshPasswordInstruction(prompt)
const sections = [languageSystem, skillsSystem, sshInstruction].filter(Boolean) as string[]
if (sections.length === 0) return undefined
return sections.join("\n\n")
}
function extractPlainTextFromParts(parts: Array<{ type?: string; text?: unknown; filename?: string }>): string {
function collectTextSegments(value: unknown, segments: string[]): void {
if (typeof value === "string") {
const trimmed = value.trim()
if (trimmed) segments.push(trimmed)
return
}
if (!value || typeof value !== "object") return
const record = value as Record<string, unknown>
if (typeof record.text === "string") {
const trimmed = record.text.trim()
if (trimmed) segments.push(trimmed)
}
if (typeof record.value === "string") {
const trimmed = record.value.trim()
if (trimmed) segments.push(trimmed)
}
const content = record.content
if (Array.isArray(content)) {
for (const item of content) {
collectTextSegments(item, segments)
}
}
}
function extractPlainTextFromParts(
parts: Array<{ type?: string; text?: unknown; filename?: string }>,
): string {
const segments: string[] = []
for (const part of parts) {
if (!part || typeof part !== "object") continue
if (part.type === "text" && typeof part.text === "string") {
segments.push(part.text)
if (part.type === "text" || part.type === "reasoning") {
collectTextSegments(part.text, segments)
} else if (part.type === "file" && typeof part.filename === "string") {
segments.push(`[file: ${part.filename}]`)
}
@@ -337,6 +374,62 @@ function buildExternalChatMessages(
return messages
}
function decodeAttachmentData(data: Uint8Array): string {
const decoder = new TextDecoder()
return decoder.decode(data)
}
function isTextLikeMime(mime?: string): boolean {
if (!mime) return false
if (mime.startsWith("text/")) return true
return ["application/json", "application/xml", "application/x-yaml"].includes(mime)
}
async function buildExternalChatMessagesWithAttachments(
instanceId: string,
sessionId: string,
systemMessage: string | undefined,
attachments: Array<{ filename?: string; source?: any; mediaType?: string }>,
): Promise<ExternalChatMessage[]> {
const baseMessages = buildExternalChatMessages(instanceId, sessionId, systemMessage)
if (!attachments || attachments.length === 0) {
return baseMessages
}
const attachmentMessages: ExternalChatMessage[] = []
for (const attachment of attachments) {
const source = attachment?.source
if (!source || typeof source !== "object") continue
let content: string | null = null
if (source.type === "text" && typeof source.value === "string") {
content = source.value
} else if (source.type === "file") {
if (source.data instanceof Uint8Array && isTextLikeMime(source.mime || attachment.mediaType)) {
content = decodeAttachmentData(source.data)
} else if (typeof source.path === "string" && source.path.length > 0) {
try {
const response = await serverApi.readWorkspaceFile(instanceId, source.path)
content = typeof response.contents === "string" ? response.contents : null
} catch {
content = null
}
}
}
if (!content) continue
const filename = attachment.filename || source.path || "attachment"
const trimmed = clampText(content, MAX_ATTACHMENT_CHARS)
attachmentMessages.push({
role: "user",
content: `Attachment: ${filename}\n\n${trimmed}`,
})
}
return [...baseMessages, ...attachmentMessages]
}
async function readSseStream(
response: Response,
onData: (data: string) => void,
@@ -396,7 +489,7 @@ async function streamOllamaChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -410,7 +503,7 @@ async function streamOllamaChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -477,7 +570,7 @@ async function streamQwenChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
accessToken: string,
resourceUrl: string | undefined,
messageId: string,
@@ -496,7 +589,7 @@ async function streamQwenChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
resource_url: resourceUrl,
}),
@@ -561,7 +654,7 @@ async function streamOpenCodeZenChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -575,7 +668,7 @@ async function streamOpenCodeZenChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -645,7 +738,7 @@ async function streamZAIChat(
sessionId: string,
providerId: string,
modelId: string,
systemMessage: string | undefined,
messages: ExternalChatMessage[],
messageId: string,
assistantMessageId: string,
assistantPartId: string,
@@ -659,7 +752,7 @@ async function streamZAIChat(
signal: controller.signal,
body: JSON.stringify({
model: modelId,
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
messages,
stream: true,
}),
})
@@ -868,6 +961,12 @@ async function sendMessage(
const now = Date.now()
const assistantMessageId = createId("msg")
const assistantPartId = createId("part")
const externalMessages = await buildExternalChatMessagesWithAttachments(
instanceId,
sessionId,
systemMessage,
attachments,
)
store.upsertMessage({
id: assistantMessageId,
@@ -902,7 +1001,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -913,7 +1012,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -924,7 +1023,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
messageId,
assistantMessageId,
assistantPartId,
@@ -962,7 +1061,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
systemMessage,
externalMessages,
token.access_token,
token.resource_url,
messageId,
@@ -1151,12 +1250,29 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
}
const agent = session.agent || "build"
let resolvedCommand = command
if (command.trim() === "build") {
try {
const response = await serverApi.fetchAvailablePort()
if (response?.port) {
const isWindows = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent)
resolvedCommand = isWindows ? `set PORT=${response.port}&& ${command}` : `PORT=${response.port} ${command}`
if (typeof window !== "undefined") {
const url = `http://localhost:${response.port}`
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, { detail: { url, instanceId } }))
}
}
} catch (error) {
log.warn("Failed to resolve available port for build", { error })
}
}
await instance.client.session.shell({
path: { id: sessionId },
body: {
agent,
command,
command: resolvedCommand,
},
})
}
@@ -1310,6 +1426,7 @@ async function compactSession(instanceId: string, sessionId: string): Promise<Co
})
log.info("compactSession: Complete", { instanceId, sessionId, compactedSessionId: compactedSession.id })
clearCompactionSuggestion(instanceId, sessionId)
return {
...result,
token_before: tokenBefore,
@@ -1407,6 +1524,30 @@ async function updateSessionModel(
updateSessionInfo(instanceId, sessionId)
}
async function updateSessionModelForSession(
instanceId: string,
sessionId: string,
model: { providerId: string; modelId: string },
): Promise<void> {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
if (!isModelValid(instanceId, model)) {
log.warn("Invalid model selection", model)
return
}
withSession(instanceId, sessionId, (current) => {
current.model = model
})
addRecentModelPreference(model)
updateSessionInfo(instanceId, sessionId)
}
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
@@ -1500,4 +1641,5 @@ export {
sendMessage,
updateSessionAgent,
updateSessionModel,
updateSessionModelForSession,
}

View File

@@ -33,6 +33,7 @@ import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { showToastNotification } from "../lib/notifications"
import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("api")
@@ -147,7 +148,7 @@ function getStoredQwenToken():
| null {
if (typeof window === "undefined") return null
try {
const raw = window.localStorage.getItem("qwen_oauth_token")
const raw = window.localStorage.getItem(getUserScopedKey("qwen_oauth_token"))
if (!raw) return null
return JSON.parse(raw)
} catch {
@@ -689,6 +690,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
}
try {
await ensureInstanceConfigLoaded(instanceId)
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
const response = await instance.client.app.agents()
const agentList = (response.data ?? []).map((agent) => ({
@@ -703,9 +705,16 @@ async function fetchAgents(instanceId: string): Promise<void> {
: undefined,
}))
const customAgents = getInstanceConfig(instanceId)?.customAgents ?? []
const customList = customAgents.map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: "custom",
}))
setAgents((prev) => {
const next = new Map(prev)
next.set(instanceId, agentList)
next.set(instanceId, [...agentList, ...customList])
return next
})
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,13 @@ import { getLogger } from "../lib/logger"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store"
import { sendMessage } from "./session-actions"
import { sendMessage, consumeTokenWarningSuppression, consumeCompactionSuppression, updateSessionModel } from "./session-actions"
import { showAlertDialog } from "./alerts"
import { sessions, setSessions, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
import { checkAndTriggerAutoCompact, getSessionCompactionState, setCompactionSuggestion } from "./session-compaction"
const log = getLogger("sse")
import { loadMessages } from "./session-api"
@@ -39,6 +40,7 @@ import {
} from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import type { InstanceMessageStore } from "./message-v2/instance-store"
import { getDefaultModel } from "./session-models"
interface TuiToastEvent {
type: "tui.toast.show"
@@ -232,6 +234,16 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
updateSessionInfo(instanceId, sessionId)
checkAndTriggerAutoCompact(instanceId, sessionId)
.then((shouldCompact) => {
if (!shouldCompact) return
if (getSessionCompactionState(instanceId, sessionId)) return
setCompactionSuggestion(instanceId, sessionId, "Context usage is high. Compact to continue.")
})
.catch((err) => {
log.error("Failed to check and trigger auto-compact", err)
})
}
}
@@ -389,6 +401,21 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
})
}
function isContextLengthError(error: any): boolean {
if (!error) return false
const errorMessage = error.data?.message || error.message || ""
return (
errorMessage.includes("maximum context length") ||
errorMessage.includes("context_length_exceeded") ||
errorMessage.includes("token count exceeds") ||
errorMessage.includes("token limit")
)
}
function isUnsupportedModelMessage(message: string): boolean {
return /model\s+.+\s+not supported/i.test(message)
}
function handleSessionError(instanceId: string, event: EventSessionError): void {
const error = event.properties?.error
log.error(`[SSE] Session error:`, error)
@@ -406,18 +433,73 @@ function handleSessionError(instanceId: string, event: EventSessionError): void
// Autonomous error recovery for SOLO
const solo = getSoloState(instanceId)
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",
})
return
}
// Check if this is a context length error
if (isContextLengthError(error)) {
if (sessionId && consumeCompactionSuppression(instanceId, sessionId)) {
showAlertDialog("Compaction failed because the model context limit was exceeded. Reduce context or switch to a larger context model, then try compact again.", {
title: "Compaction failed",
variant: "error",
})
return
}
if (sessionId && consumeTokenWarningSuppression(instanceId, sessionId)) {
showToastNotification({
title: "Context limit exceeded",
message: "Compaction is required before continuing.",
variant: "warning",
duration: 7000,
})
return
}
log.info("Context length error detected; suggesting compaction", { instanceId, sessionId })
if (sessionId) {
setCompactionSuggestion(instanceId, sessionId, "Context limit exceeded. Compact to continue.")
showToastNotification({
title: "Compaction required",
message: "Click Compact to continue this session.",
variant: "warning",
duration: 8000,
})
} else {
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
return
}
if (sessionId && isUnsupportedModelMessage(message)) {
showToastNotification({
title: "Model not supported",
message: "Selected model is not supported by this provider. Reverting to a default model.",
variant: "warning",
duration: 8000,
})
const sessionRecord = sessions().get(instanceId)?.get(sessionId)
getDefaultModel(instanceId, sessionRecord?.agent)
.then((fallback) => updateSessionModel(instanceId, sessionId, fallback))
.catch((err) => log.error("Failed to restore default model after unsupported model error", err))
return
}
// Default error handling
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {

View File

@@ -2,7 +2,7 @@ import type { Session, SessionStatus } from "../types/session"
import type { MessageInfo } from "../types/message"
import type { MessageRecord } from "./message-v2/types"
import { sessions } from "./sessions"
import { isSessionCompactionActive } from "./session-compaction"
import { getSessionCompactionState } from "./session-compaction"
import { messageStoreBus } from "./message-v2/bus"
function getSession(instanceId: string, sessionId: string): Session | null {
@@ -120,7 +120,7 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
const store = messageStoreBus.getOrCreate(instanceId)
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
if (getSessionCompactionState(instanceId, sessionId) || isSessionCompacting(session)) {
return "compacting"
}

View File

@@ -1,7 +1,8 @@
import { withSession } from "./session-state"
import { sessions, withSession } from "./session-state"
import { Task, TaskStatus } from "../types/session"
import { nanoid } from "nanoid"
import { forkSession } from "./session-api"
import { createSession } from "./session-api"
import { showToastNotification } from "../lib/notifications"
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
withSession(instanceId, sessionId, (session) => {
@@ -18,13 +19,32 @@ export async function addTask(
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
let taskSessionId: string | undefined
const parentSession = sessions().get(instanceId)?.get(sessionId)
const parentAgent = parentSession?.agent || ""
const parentModel = parentSession?.model
try {
console.log("[task-actions] forking session...");
const forked = await forkSession(instanceId, sessionId)
taskSessionId = forked.id
console.log("[task-actions] fork successful", { taskSessionId });
console.log("[task-actions] creating new task session...");
const created = await createSession(instanceId, parentAgent || undefined, { skipAutoCleanup: true })
taskSessionId = created.id
withSession(instanceId, taskSessionId, (taskSession) => {
taskSession.parentId = sessionId
if (parentAgent) {
taskSession.agent = parentAgent
}
if (parentModel?.providerId && parentModel?.modelId) {
taskSession.model = { ...parentModel }
}
})
console.log("[task-actions] task session created", { taskSessionId });
} catch (error) {
console.error("[task-actions] Failed to fork session for task", error)
console.error("[task-actions] Failed to create session for task", error)
showToastNotification({
title: "Task session unavailable",
message: "Continuing in the current session.",
variant: "warning",
duration: 5000,
})
taskSessionId = undefined
}
const newTask: Task = {
@@ -34,6 +54,7 @@ export async function addTask(
timestamp: Date.now(),
messageIds: [],
taskSessionId,
archived: false,
}
withSession(instanceId, sessionId, (session) => {
@@ -161,3 +182,15 @@ export function removeTask(instanceId: string, sessionId: string, taskId: string
}
})
}
export function archiveTask(instanceId: string, sessionId: string, taskId: string): void {
withSession(instanceId, sessionId, (session) => {
if (!session.tasks) return
session.tasks = session.tasks.map((task) =>
task.id === taskId ? { ...task, archived: true } : task,
)
if (session.activeTaskId === taskId) {
session.activeTaskId = undefined
}
})
}

View File

@@ -4,6 +4,7 @@ const [hasInstances, setHasInstances] = createSignal(false)
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
const [showFolderSelection, setShowFolderSelection] = createSignal(false)
const [showFolderSelectionOnStart, setShowFolderSelectionOnStart] = createSignal(true)
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
@@ -29,6 +30,8 @@ export {
setIsSelectingFolder,
showFolderSelection,
setShowFolderSelection,
showFolderSelectionOnStart,
setShowFolderSelectionOnStart,
instanceTabOrder,
setInstanceTabOrder,
sessionTabOrder,

View File

@@ -0,0 +1,89 @@
import { createSignal } from "solid-js"
import { getLogger } from "../lib/logger"
export interface UserAccount {
id: string
name: string
isGuest?: boolean
}
const log = getLogger("users")
const [users, setUsers] = createSignal<UserAccount[]>([])
const [activeUser, setActiveUserSignal] = createSignal<UserAccount | null>(null)
const [loadingUsers, setLoadingUsers] = createSignal(false)
function getElectronApi() {
return typeof window !== "undefined" ? window.electronAPI : undefined
}
async function refreshUsers(): Promise<void> {
const api = getElectronApi()
if (!api?.listUsers) return
setLoadingUsers(true)
try {
const list = await api.listUsers()
setUsers(list ?? [])
const active = api.getActiveUser ? await api.getActiveUser() : null
setActiveUserSignal(active ?? null)
} catch (error) {
log.warn("Failed to load users", error)
} finally {
setLoadingUsers(false)
}
}
async function createUser(name: string, password: string): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.createUser) return null
const user = await api.createUser({ name, password })
await refreshUsers()
return user ?? null
}
async function updateUser(id: string, updates: { name?: string; password?: string }): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.updateUser) return null
const user = await api.updateUser({ id, ...updates })
await refreshUsers()
return user ?? null
}
async function deleteUser(id: string): Promise<void> {
const api = getElectronApi()
if (!api?.deleteUser) return
await api.deleteUser({ id })
await refreshUsers()
}
async function loginUser(id: string, password?: string): Promise<boolean> {
const api = getElectronApi()
if (!api?.loginUser) return false
const result = await api.loginUser({ id, password })
if (result?.success) {
setActiveUserSignal(result.user ?? null)
await refreshUsers()
return true
}
return false
}
async function createGuest(): Promise<UserAccount | null> {
const api = getElectronApi()
if (!api?.createGuest) return null
const user = await api.createGuest()
await refreshUsers()
return user ?? null
}
export {
users,
activeUser,
loadingUsers,
refreshUsers,
createUser,
updateUser,
deleteUser,
loginUser,
createGuest,
}

View File

@@ -0,0 +1,95 @@
.mcp-manager {
@apply flex flex-col gap-3;
}
.mcp-manager-header {
@apply flex items-center justify-between;
}
.mcp-manager-actions {
@apply flex items-center gap-2;
}
.mcp-action-button {
@apply flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white;
}
.mcp-link-button {
@apply px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 hover:text-white;
}
.mcp-icon-button {
@apply p-1 rounded-md border border-white/10 text-zinc-400 hover:text-white hover:border-white/20;
}
.mcp-menu {
@apply absolute right-0 mt-2 w-48 rounded-md border border-white/10 bg-zinc-950 shadow-xl z-50 overflow-hidden;
}
.mcp-menu-item {
@apply w-full px-3 py-2 text-left text-[11px] text-zinc-300 hover:text-white hover:bg-white/5 flex items-center justify-between gap-2;
}
.mcp-server-list {
@apply flex flex-col gap-2;
}
.mcp-server-card {
@apply px-2 py-2 rounded border bg-white/5 border-white/10;
}
.mcp-server-row {
@apply flex items-center justify-between gap-2;
}
.mcp-status-chip {
@apply text-[10px] px-2 py-0.5 rounded-full border border-emerald-500/40 text-emerald-300 bg-emerald-500/10 uppercase tracking-wide;
}
.mcp-status-error {
@apply text-[10px] px-2 py-0.5 rounded-full border border-rose-500/40 text-rose-300 bg-rose-500/10 uppercase tracking-wide;
}
.mcp-market-search {
@apply flex items-center gap-2 border border-white/10 rounded-lg px-3 py-2 bg-white/5;
}
.mcp-market-input {
@apply w-full bg-transparent text-xs text-zinc-200 outline-none;
}
.mcp-market-list {
@apply flex flex-col gap-2 max-h-[60vh] overflow-y-auto pr-1;
}
.mcp-market-card {
@apply flex items-start justify-between gap-4 border border-white/10 rounded-lg bg-white/5 p-3;
}
.mcp-market-card-title {
@apply text-xs font-semibold text-zinc-100 flex items-center gap-2;
}
.mcp-market-card-desc {
@apply text-[11px] text-zinc-500 mt-1;
}
.mcp-market-tags {
@apply flex flex-wrap gap-1 mt-2;
}
.mcp-market-tag {
@apply text-[9px] uppercase tracking-wide px-2 py-0.5 rounded-full border border-white/10 text-zinc-400;
}
.mcp-market-source {
@apply text-[9px] uppercase tracking-wide px-2 py-0.5 rounded-full border border-white/10 text-zinc-500;
}
.mcp-market-card-actions {
@apply flex items-center gap-2;
}
.mcp-market-install {
@apply flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 hover:text-white;
}

View File

@@ -6,3 +6,4 @@
@import "./components/env-vars.css";
@import "./components/directory-browser.css";
@import "./components/remote-access.css";
@import "./components/mcp-manager.css";

View File

@@ -53,6 +53,20 @@
@apply text-[11px] text-[var(--text-muted)];
}
.message-model-badge {
@apply inline-flex items-center px-2 py-1 rounded-full border;
border-color: var(--border-base);
background-color: var(--surface-secondary);
color: var(--text-muted);
transition: all 0.2s ease;
}
.message-model-badge:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
background-color: var(--surface-hover);
}
.assistant-message {
/* gap: 0.25rem; */
padding: 0.6rem 0.65rem;
@@ -121,6 +135,13 @@
border-color: var(--status-error);
}
.compact-button {
@apply ml-2 px-2 py-1 rounded bg-emerald-500/20 border border-emerald-500/40 text-emerald-400 text-xs font-semibold hover:bg-emerald-500/30 transition-all;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.message-generating {
@apply text-sm italic py-2;
color: var(--text-muted);
@@ -146,6 +167,58 @@
animation: pulse 1.5s ease-in-out infinite;
}
.message-streaming-indicator {
@apply inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-purple-500/50 bg-purple-500/10 mb-2;
}
.streaming-status {
@apply inline-flex items-center gap-2 text-sm;
}
.streaming-pulse {
@apply inline-block w-2 h-2 rounded-full bg-purple-500;
animation: streaming-pulse 1s ease-in-out infinite;
}
@keyframes streaming-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 8px 4px rgba(168, 85, 247, 0.6);
}
50% {
opacity: 0.6;
transform: scale(0.8);
box-shadow: 0 0 12px 6px rgba(168, 85, 247, 0.8);
}
}
.streaming-text {
@apply text-purple-400 font-semibold tracking-wide;
animation: streaming-text-pulse 1.5s ease-in-out infinite;
}
@keyframes streaming-text-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.streaming-tokens {
@apply inline-flex items-center gap-1 px-2 py-1 rounded-full bg-purple-500/20 border border-purple-500/30;
}
.streaming-token-count {
@apply text-purple-300 font-mono font-bold;
}
.streaming-token-label {
@apply text-purple-400 text-xs font-medium;
}
.message-text {
font-size: var(--font-size-base);
line-height: var(--line-height-normal);

View File

@@ -1,7 +1,27 @@
.message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
@apply flex-1 min-h-0 overflow-y-auto overflow-x-hidden flex flex-col gap-0.5;
background-color: var(--surface-base);
color: inherit;
scrollbar-width: thin;
scrollbar-color: var(--border-base) transparent;
scrollbar-gutter: stable;
}
.message-stream::-webkit-scrollbar {
width: 8px;
}
.message-stream::-webkit-scrollbar-track {
background: transparent;
}
.message-stream::-webkit-scrollbar-thumb {
background: var(--border-base);
border-radius: 4px;
}
.message-stream::-webkit-scrollbar-thumb:hover {
background: var(--border-base-hover);
}
.message-stream-block {

View File

@@ -1,5 +1,54 @@
.message-stream-container {
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
width: 100%;
}
.compaction-banner {
@apply sticky top-0 z-10 flex items-center gap-2 px-4 py-2 text-xs font-medium;
background-color: var(--surface-secondary);
color: var(--text-primary);
border-bottom: 1px solid var(--border-base);
}
.compaction-banner-spinner {
@apply w-4 h-4 border-2 border-t-transparent rounded-full;
border-color: var(--border-base);
border-top-color: var(--accent-primary);
animation: spin 1s linear infinite;
}
.compaction-suggestion {
@apply sticky top-0 z-10 flex items-center justify-between gap-3 px-4 py-2 text-xs font-medium;
background-color: rgba(22, 163, 74, 0.12);
color: var(--text-primary);
border-bottom: 1px solid rgba(22, 163, 74, 0.35);
}
.compaction-suggestion-text {
@apply flex flex-col gap-0.5;
}
.compaction-suggestion-label {
@apply uppercase tracking-wide text-[10px] font-semibold;
color: rgba(74, 222, 128, 0.9);
}
.compaction-suggestion-message {
color: var(--text-secondary);
}
.compaction-suggestion-action {
@apply inline-flex items-center justify-center px-3 py-1.5 rounded-lg text-[10px] font-semibold uppercase tracking-wide;
border: 1px solid rgba(34, 197, 94, 0.5);
background-color: rgba(34, 197, 94, 0.2);
color: #4ade80;
transition: transform 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.compaction-suggestion-action:hover {
background-color: rgba(34, 197, 94, 0.3);
color: #86efac;
transform: translateY(-1px);
}
.connection-status {

View File

@@ -38,12 +38,14 @@
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.message-stream-shell .message-stream {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.message-timeline-sidebar {

View File

@@ -1,6 +1,6 @@
/* Prompt input & attachment styles */
.prompt-input-container {
@apply flex flex-col relative mx-auto w-full max-w-4xl;
@apply flex flex-col relative mx-auto w-full max-w-4xl flex-shrink-0;
padding: 1rem 1.5rem 1.5rem;
background-color: transparent;
}
@@ -32,6 +32,20 @@
@apply absolute right-2 bottom-2 flex items-center space-x-1.5 z-20;
}
.thinking-indicator {
@apply flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-semibold uppercase tracking-wide;
border: 1px solid rgba(129, 140, 248, 0.4);
background-color: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
}
.thinking-spinner {
@apply w-3 h-3 border-2 border-t-transparent rounded-full;
border-color: rgba(129, 140, 248, 0.4);
border-top-color: #a5b4fc;
animation: spin 0.9s linear infinite;
}
.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;
}

View File

@@ -214,7 +214,7 @@
}
.tool-call-diff-toolbar {
@apply flex items-center justify-between gap-3 px-3 py-2;
@apply flex flex-wrap items-center gap-3 px-3 py-2;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
}
@@ -227,7 +227,7 @@
}
.tool-call-diff-toggle {
@apply inline-flex items-center gap-1;
@apply inline-flex flex-wrap items-center gap-1;
}
.tool-call-diff-mode-button {

View File

@@ -0,0 +1,191 @@
/* Responsive Design for Electron Interface */
/* Base container adjustments for small screens */
@media (max-width: 640px) {
.session-shell-panels {
overflow: hidden;
}
.session-toolbar {
padding: 0.25rem 0.5rem;
min-height: 36px;
}
.session-toolbar button,
.session-toolbar .icon-button {
padding: 0.25rem;
}
.session-toolbar .hidden.md\:flex {
display: none !important;
}
.content-area {
min-width: 0;
}
}
/* Tablet adjustments */
@media (min-width: 641px) and (max-width: 1024px) {
.session-toolbar {
padding: 0.5rem 0.75rem;
}
.content-area {
min-width: 0;
}
}
/* Desktop adjustments */
@media (min-width: 1025px) {
.content-area {
min-width: 0;
}
}
/* Ensure all scrollable containers handle overflow properly */
@media (max-width: 768px) {
.flex-1.min-h-0 {
min-height: 0;
flex: 1 1 0%;
}
.overflow-y-auto {
overflow-y: auto;
}
.min-h-0 {
min-height: 0;
}
}
/* Fix drawer widths on mobile */
@media (max-width: 768px) {
.session-sidebar-container,
.session-right-panel {
max-width: 100vw !important;
}
}
/* Chat panel adjustments for small screens */
@media (max-width: 640px) {
.flex.flex-col.relative.border-l {
min-width: 280px !important;
}
}
/* Terminal adjustments */
@media (max-width: 768px) {
.terminal-panel {
min-height: 100px !important;
max-height: 40vh !important;
}
}
/* Prevent horizontal scroll on root levels only */
html,
body,
#root {
overflow-x: hidden;
width: 100%;
max-width: 100vw;
}
/* Ensure proper flex sizing throughout the app */
.flex-1 {
flex: 1 1 0%;
}
.min-h-0 {
min-height: 0;
}
/* Ensure scrollable containers work correctly */
.overflow-y-auto {
overflow-y: auto;
overflow-x: hidden;
}
/* Ensure viewport meta tag behavior */
@viewport {
width: device-width;
zoom: 1.0;
}
/* Touch-friendly adjustments for mobile */
@media (hover: none) and (pointer: coarse) {
.session-resize-handle {
width: 16px;
}
.message-scroll-button {
width: 3rem;
height: 3rem;
}
}
/* High DPI adjustments */
@media (-webkit-min-device-pixel-ratio: 2),
(min-resolution: 192dpi) {
/* Enhance text rendering on high-dpi screens */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Ensure the main app container is fully adaptive */
.app-container,
[data-app-container="true"],
#root>div {
width: 100%;
height: 100%;
max-width: 100vw;
max-height: 100vh;
overflow: hidden;
}
/* Fix message navigation sidebar from being cut off */
.message-navigation-sidebar,
[class*="message-nav"],
.shrink-0.overflow-y-auto.border-l {
min-width: 28px;
max-width: 48px;
flex-shrink: 0;
overflow-x: hidden;
}
/* Ensure panels don't overflow their containers */
.panel,
[role="main"],
main {
max-width: 100%;
overflow-x: hidden;
}
/* Fix right-side badges and avatars */
.message-avatar,
.message-role-badge,
[class*="shrink-0"][class*="border-l"] {
min-width: min-content;
overflow: visible;
}
/* Ensure proper Electron window behavior */
@media screen {
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
}
/* Text rendering optimization */
body {
-webkit-font-smoothing: subpixel-antialiased;
}

View File

@@ -233,23 +233,6 @@
--new-tab-bg: #3f3f46;
--new-tab-hover-bg: #52525b;
--new-tab-text: #f5f6f8;
--session-tab-active-bg: var(--surface-muted);
--session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted);
--session-tab-hover-bg: #3f3f46;
--button-primary-bg: #3f3f46;
--button-primary-hover-bg: #52525b;
--button-primary-text: #f5f6f8;
--tab-active-bg: #3f3f46;
--tab-active-hover-bg: #52525b;
--tab-active-text: #f5f6f8;
--tab-inactive-bg: #2f2f36;
--tab-inactive-hover-bg: #3d3d45;
--tab-inactive-text: #d4d4d8;
--new-tab-bg: #3f3f46;
--new-tab-hover-bg: #52525b;
--new-tab-text: #f5f6f8;
--session-tab-active-bg: var(--surface-muted);
--session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted);

View File

@@ -48,7 +48,22 @@
}
.icon-danger-hover:hover {
color: var(--status-error);
color: #ef4444;
}
/* Tooltip styles */
.tooltip-content {
background-color: var(--surface-base);
border: 1px solid var(--border-base);
color: var(--text-primary);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
max-width: 200px;
z-index: 1000;
}
.tooltip-content .font-bold {
color: var(--accent-primary);
}
.icon-accent-hover:hover {

View File

@@ -26,6 +26,13 @@ declare global {
onCliError?: (callback: (data: unknown) => void) => () => void
getCliStatus?: () => Promise<unknown>
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
listUsers?: () => Promise<Array<{ id: string; name: string; isGuest?: boolean }>>
getActiveUser?: () => Promise<{ id: string; name: string; isGuest?: boolean } | null>
createUser?: (payload: { name: string; password: string }) => Promise<{ id: string; name: string; isGuest?: boolean }>
updateUser?: (payload: { id: string; name?: string; password?: string }) => Promise<{ id: string; name: string; isGuest?: boolean }>
deleteUser?: (payload: { id: string }) => Promise<{ success: boolean }>
createGuest?: () => Promise<{ id: string; name: string; isGuest?: boolean }>
loginUser?: (payload: { id: string; password?: string }) => Promise<{ success: boolean; user?: { id: string; name: string; isGuest?: boolean } }>
}
interface TauriDialogModule {

View File

@@ -4,6 +4,7 @@ import { resolve } from "path"
export default defineConfig({
root: "./src/renderer",
publicDir: resolve(__dirname, "./public"),
plugins: [solid()],
css: {
postcss: "./postcss.config.js",
@@ -20,10 +21,11 @@ export default defineConfig({
noExternal: ["lucide-solid"],
},
server: {
port: 3000,
port: Number(process.env.VITE_PORT ?? 3000),
},
build: {
outDir: "dist",
outDir: resolve(__dirname, "dist"),
chunkSizeWarningLimit: 1000,
rollupOptions: {
input: {
main: resolve(__dirname, "./src/renderer/index.html"),

View File

@@ -0,0 +1,41 @@
// vite.config.ts
import { defineConfig } from "file:///E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/vite/dist/node/index.js";
import solid from "file:///E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { resolve } from "path";
var __vite_injected_original_dirname = "E:\\TRAE Playground\\NeuralNomadsAi\\NomadArch\\packages\\ui";
var vite_config_default = defineConfig({
root: "./src/renderer",
publicDir: resolve(__vite_injected_original_dirname, "./public"),
plugins: [solid()],
css: {
postcss: "./postcss.config.js"
},
resolve: {
alias: {
"@": resolve(__vite_injected_original_dirname, "./src")
}
},
optimizeDeps: {
exclude: ["lucide-solid"]
},
ssr: {
noExternal: ["lucide-solid"]
},
server: {
port: Number(process.env.VITE_PORT ?? 3e3)
},
build: {
outDir: resolve(__vite_injected_original_dirname, "dist"),
chunkSizeWarningLimit: 1e3,
rollupOptions: {
input: {
main: resolve(__vite_injected_original_dirname, "./src/renderer/index.html"),
loading: resolve(__vite_injected_original_dirname, "./src/renderer/loading.html")
}
}
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJFOlxcXFxUUkFFIFBsYXlncm91bmRcXFxcTmV1cmFsTm9tYWRzQWlcXFxcTm9tYWRBcmNoXFxcXHBhY2thZ2VzXFxcXHVpXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJFOlxcXFxUUkFFIFBsYXlncm91bmRcXFxcTmV1cmFsTm9tYWRzQWlcXFxcTm9tYWRBcmNoXFxcXHBhY2thZ2VzXFxcXHVpXFxcXHZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9FOi9UUkFFJTIwUGxheWdyb3VuZC9OZXVyYWxOb21hZHNBaS9Ob21hZEFyY2gvcGFja2FnZXMvdWkvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiXHJcbmltcG9ydCBzb2xpZCBmcm9tIFwidml0ZS1wbHVnaW4tc29saWRcIlxyXG5pbXBvcnQgeyByZXNvbHZlIH0gZnJvbSBcInBhdGhcIlxyXG5cclxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcclxuICByb290OiBcIi4vc3JjL3JlbmRlcmVyXCIsXHJcbiAgcHVibGljRGlyOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3B1YmxpY1wiKSxcclxuICBwbHVnaW5zOiBbc29saWQoKV0sXHJcbiAgY3NzOiB7XHJcbiAgICBwb3N0Y3NzOiBcIi4vcG9zdGNzcy5jb25maWcuanNcIixcclxuICB9LFxyXG4gIHJlc29sdmU6IHtcclxuICAgIGFsaWFzOiB7XHJcbiAgICAgIFwiQFwiOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyY1wiKSxcclxuICAgIH0sXHJcbiAgfSxcclxuICBvcHRpbWl6ZURlcHM6IHtcclxuICAgIGV4Y2x1ZGU6IFtcImx1Y2lkZS1zb2xpZFwiXSxcclxuICB9LFxyXG4gIHNzcjoge1xyXG4gICAgbm9FeHRlcm5hbDogW1wibHVjaWRlLXNvbGlkXCJdLFxyXG4gIH0sXHJcbiAgc2VydmVyOiB7XHJcbiAgICBwb3J0OiBOdW1iZXIocHJvY2Vzcy5lbnYuVklURV9QT1JUID8/IDMwMDApLFxyXG4gIH0sXHJcbiAgYnVpbGQ6IHtcclxuICAgIG91dERpcjogcmVzb2x2ZShfX2Rpcm5hbWUsIFwiZGlzdFwiKSxcclxuICAgIGNodW5rU2l6ZVdhcm5pbmdMaW1pdDogMTAwMCxcclxuICAgIHJvbGx1cE9wdGlvbnM6IHtcclxuICAgICAgaW5wdXQ6IHtcclxuICAgICAgICBtYWluOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyYy9yZW5kZXJlci9pbmRleC5odG1sXCIpLFxyXG4gICAgICAgIGxvYWRpbmc6IHJlc29sdmUoX19kaXJuYW1lLCBcIi4vc3JjL3JlbmRlcmVyL2xvYWRpbmcuaHRtbFwiKSxcclxuICAgICAgfSxcclxuICAgIH0sXHJcbiAgfSxcclxufSlcclxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFxVyxTQUFTLG9CQUFvQjtBQUNsWSxPQUFPLFdBQVc7QUFDbEIsU0FBUyxlQUFlO0FBRnhCLElBQU0sbUNBQW1DO0FBSXpDLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLE1BQU07QUFBQSxFQUNOLFdBQVcsUUFBUSxrQ0FBVyxVQUFVO0FBQUEsRUFDeEMsU0FBUyxDQUFDLE1BQU0sQ0FBQztBQUFBLEVBQ2pCLEtBQUs7QUFBQSxJQUNILFNBQVM7QUFBQSxFQUNYO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUCxPQUFPO0FBQUEsTUFDTCxLQUFLLFFBQVEsa0NBQVcsT0FBTztBQUFBLElBQ2pDO0FBQUEsRUFDRjtBQUFBLEVBQ0EsY0FBYztBQUFBLElBQ1osU0FBUyxDQUFDLGNBQWM7QUFBQSxFQUMxQjtBQUFBLEVBQ0EsS0FBSztBQUFBLElBQ0gsWUFBWSxDQUFDLGNBQWM7QUFBQSxFQUM3QjtBQUFBLEVBQ0EsUUFBUTtBQUFBLElBQ04sTUFBTSxPQUFPLFFBQVEsSUFBSSxhQUFhLEdBQUk7QUFBQSxFQUM1QztBQUFBLEVBQ0EsT0FBTztBQUFBLElBQ0wsUUFBUSxRQUFRLGtDQUFXLE1BQU07QUFBQSxJQUNqQyx1QkFBdUI7QUFBQSxJQUN2QixlQUFlO0FBQUEsTUFDYixPQUFPO0FBQUEsUUFDTCxNQUFNLFFBQVEsa0NBQVcsMkJBQTJCO0FBQUEsUUFDcEQsU0FBUyxRQUFRLGtDQUFXLDZCQUE2QjtBQUFBLE1BQzNEO0FBQUEsSUFDRjtBQUFBLEVBQ0Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=