* feat(docker): add Docker support with multi-stage build and CI workflow - Introduced a `.dockerignore` file to exclude unnecessary files from the Docker context. - Added a `Dockerfile` for multi-stage builds, optimizing the application for production with a slim runtime. - Created a GitHub Actions workflow (`docker.yml`) to automate the building and pushing of Docker images on version tag pushes. - Enhanced the `connect-agent.ts` and `install-agent.ts` files to improve OpenCode binary resolution and installation commands. - Updated the canvas components to support new polygon shape and related functionalities, including UI adjustments for shape tools and appearance settings. This update significantly enhances the deployment process and expands the application's capabilities with Docker integration. * feat(docker): enhance Dockerfile with multi-stage builds and CLI variants - Updated the Dockerfile to include multiple image variants for different CLI tools, including Claude, Codex, OpenCode, and Copilot, alongside the base web app. - Improved the build process by separating the build stage and production stage, optimizing the final image size. - Added environment variables and commands for each variant to ensure proper execution in production. - Enhanced the README files in multiple languages to document the new Docker deployment options and usage instructions. This update significantly expands the Docker deployment capabilities, allowing users to choose the appropriate image variant based on their needs.
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
|
import type { GroupedModel } from '../../../src/types/agent-settings'
|
|
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
|
import {
|
|
buildClaudeAgentEnv,
|
|
getClaudeAgentDebugFilePath,
|
|
} from '../../utils/resolve-claude-agent-env'
|
|
|
|
interface ConnectBody {
|
|
agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot'
|
|
}
|
|
|
|
interface ConnectResult {
|
|
connected: boolean
|
|
models: GroupedModel[]
|
|
error?: string
|
|
notInstalled?: boolean
|
|
}
|
|
|
|
/**
|
|
* POST /api/ai/connect-agent
|
|
* Actively connects to a local CLI tool and fetches its supported models.
|
|
*/
|
|
export default defineEventHandler(async (event) => {
|
|
const body = await readBody<ConnectBody>(event)
|
|
setResponseHeaders(event, { 'Content-Type': 'application/json' })
|
|
|
|
if (!body?.agent) {
|
|
return { connected: false, models: [], error: 'Missing agent field' } satisfies ConnectResult
|
|
}
|
|
|
|
if (body.agent === 'claude-code') {
|
|
return connectClaudeCode()
|
|
}
|
|
|
|
if (body.agent === 'codex-cli') {
|
|
return connectCodexCli()
|
|
}
|
|
|
|
if (body.agent === 'opencode') {
|
|
return connectOpenCode()
|
|
}
|
|
|
|
if (body.agent === 'copilot') {
|
|
return connectCopilot()
|
|
}
|
|
|
|
return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult
|
|
})
|
|
|
|
/**
|
|
* Fallback models when supportedModels() fails.
|
|
* Used with third-party API proxies (e.g. Claude Router) that don't support
|
|
* the model-listing endpoint. Covers common model IDs routers typically expose.
|
|
*/
|
|
const FALLBACK_CLAUDE_MODELS: GroupedModel[] = [
|
|
{ value: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6', description: '', provider: 'anthropic' },
|
|
{ value: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', description: '', provider: 'anthropic' },
|
|
{ value: 'claude-sonnet-4-5-20250514', displayName: 'Claude Sonnet 4.5', description: '', provider: 'anthropic' },
|
|
{ value: 'claude-haiku-4-5-20251001', displayName: 'Claude Haiku 4.5', description: '', provider: 'anthropic' },
|
|
{ value: 'claude-3-7-sonnet-20250219', displayName: 'Claude 3.7 Sonnet', description: '', provider: 'anthropic' },
|
|
{ value: 'claude-3-5-sonnet-20241022', displayName: 'Claude 3.5 Sonnet', description: '', provider: 'anthropic' },
|
|
{ value: 'claude-3-5-haiku-20241022', displayName: 'Claude 3.5 Haiku', description: '', provider: 'anthropic' },
|
|
]
|
|
|
|
/** Connect to Claude Code via Agent SDK and fetch real supported models */
|
|
async function connectClaudeCode(): Promise<ConnectResult> {
|
|
const claudePath = resolveClaudeCli()
|
|
if (!claudePath) {
|
|
return { connected: false, models: [], notInstalled: true, error: 'Claude Code CLI not found' }
|
|
}
|
|
|
|
try {
|
|
const { query } = await import('@anthropic-ai/claude-agent-sdk')
|
|
|
|
const env = buildClaudeAgentEnv()
|
|
const debugFile = getClaudeAgentDebugFilePath()
|
|
|
|
const q = query({
|
|
prompt: '',
|
|
options: {
|
|
maxTurns: 1,
|
|
tools: [],
|
|
permissionMode: 'plan',
|
|
persistSession: false,
|
|
env,
|
|
...(debugFile ? { debugFile } : {}),
|
|
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
|
},
|
|
})
|
|
|
|
const raw = await q.supportedModels()
|
|
q.close()
|
|
|
|
const models: GroupedModel[] = raw.map((m) => ({
|
|
value: m.value,
|
|
displayName: m.displayName,
|
|
description: m.description,
|
|
provider: 'anthropic' as const,
|
|
}))
|
|
|
|
return { connected: true, models }
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : 'Failed to connect'
|
|
// Third-party API proxies often don't support the supportedModels() call,
|
|
// causing "query closed before response". Fall back to a default model list
|
|
// so users can still connect and choose a model.
|
|
if (/closed before|closed early|query closed/i.test(msg)) {
|
|
return { connected: true, models: FALLBACK_CLAUDE_MODELS }
|
|
}
|
|
return { connected: false, models: [], error: friendlyClaudeError(msg) }
|
|
}
|
|
}
|
|
|
|
/** Map raw Agent SDK errors to user-friendly messages */
|
|
function friendlyClaudeError(raw: string): string {
|
|
if (/process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)) {
|
|
return 'Claude Code exited with code 1. Run "claude login" to authenticate, or set ANTHROPIC_API_KEY in ~/.claude/settings.json.'
|
|
}
|
|
if (/exited with code/i.test(raw)) {
|
|
return 'Unable to connect. Claude Code process exited unexpectedly.'
|
|
}
|
|
if (/not found|ENOENT/i.test(raw)) {
|
|
return 'Claude Code CLI not found. Please install it first.'
|
|
}
|
|
if (/timed?\s*out/i.test(raw)) {
|
|
return 'Connection timed out. Please try again.'
|
|
}
|
|
return raw
|
|
}
|
|
|
|
/** Connect to Codex CLI and fetch its supported models from the local cache */
|
|
async function connectCodexCli(): Promise<ConnectResult> {
|
|
try {
|
|
const { execSync } = await import('node:child_process')
|
|
const { readFile } = await import('node:fs/promises')
|
|
const { homedir } = await import('node:os')
|
|
const { join } = await import('node:path')
|
|
|
|
// Check if codex binary exists
|
|
const whichCmd = process.platform === 'win32' ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""'
|
|
const which = execSync(whichCmd, {
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
}).trim().split(/\r?\n/)[0]?.trim() ?? ''
|
|
|
|
if (!which) {
|
|
return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' }
|
|
}
|
|
|
|
// Verify codex is responsive
|
|
try {
|
|
execSync('codex --version 2>&1', { encoding: 'utf-8', timeout: 5000 })
|
|
} catch {
|
|
return { connected: false, models: [], error: 'Codex CLI not responding' }
|
|
}
|
|
|
|
// Read models from Codex CLI's local models cache
|
|
let models: GroupedModel[] = []
|
|
const cachePath = join(homedir(), '.codex', 'models_cache.json')
|
|
|
|
try {
|
|
const raw = await readFile(cachePath, 'utf-8')
|
|
const cache = JSON.parse(raw) as {
|
|
models?: Array<{
|
|
slug: string
|
|
display_name: string
|
|
description: string
|
|
visibility: string
|
|
priority: number
|
|
}>
|
|
}
|
|
|
|
if (cache.models && Array.isArray(cache.models)) {
|
|
models = cache.models
|
|
.filter((m) => m.visibility === 'list')
|
|
.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999))
|
|
.map((m) => ({
|
|
value: m.slug,
|
|
displayName: m.display_name,
|
|
description: m.description ?? '',
|
|
provider: 'openai' as const,
|
|
}))
|
|
}
|
|
} catch {
|
|
// Cache file not found or unreadable
|
|
}
|
|
|
|
if (models.length === 0) {
|
|
return { connected: false, models: [], error: 'No models found. Try running codex once to populate the model cache.' }
|
|
}
|
|
|
|
return { connected: true, models }
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : 'Failed to connect'
|
|
return { connected: false, models: [], error: msg }
|
|
}
|
|
}
|
|
|
|
/** Resolve the opencode binary path, checking PATH then common install locations. */
|
|
async function resolveOpencodeBinary(): Promise<string | undefined> {
|
|
const { execSync } = await import('node:child_process')
|
|
const { existsSync } = await import('node:fs')
|
|
const { homedir } = await import('node:os')
|
|
const { join } = await import('node:path')
|
|
const isWin = process.platform === 'win32'
|
|
|
|
// 1. Try PATH lookup
|
|
try {
|
|
const cmd = isWin ? 'where opencode' : 'which opencode 2>/dev/null'
|
|
const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]?.trim()
|
|
if (result && existsSync(result)) return result
|
|
} catch { /* not in PATH */ }
|
|
|
|
// 2. Try `npm prefix -g` to find actual npm global bin directory
|
|
// On Windows, must use `npm.cmd` since Electron spawns cmd.exe
|
|
try {
|
|
const npmCmd = isWin ? 'npm.cmd prefix -g' : 'npm prefix -g'
|
|
const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim()
|
|
if (prefix) {
|
|
const bin = isWin ? join(prefix, 'opencode.cmd') : join(prefix, 'bin', 'opencode')
|
|
if (existsSync(bin)) return bin
|
|
}
|
|
} catch { /* npm not available */ }
|
|
|
|
// 3. Common install locations
|
|
// npm -g → %APPDATA%\npm (Windows), /usr/local (macOS/Linux)
|
|
// curl installer → ~/.opencode/bin (macOS/Linux)
|
|
// Homebrew → /usr/local/bin or /opt/homebrew/bin (macOS)
|
|
const home = homedir()
|
|
const candidates = isWin
|
|
? [
|
|
// npm global
|
|
join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
|
|
join(process.env.ProgramFiles || '', 'nodejs', 'opencode.cmd'),
|
|
// nvm-windows / fnm
|
|
join(process.env.NVM_SYMLINK || '', 'opencode.cmd'),
|
|
join(process.env.FNM_MULTISHELL_PATH || '', 'opencode.cmd'),
|
|
// Scoop
|
|
join(home, 'scoop', 'shims', 'opencode.exe'),
|
|
join(process.env.LOCALAPPDATA || '', 'Programs', 'opencode', 'opencode.exe'),
|
|
]
|
|
: [
|
|
// curl installer (https://opencode.ai/install)
|
|
join(home, '.opencode', 'bin', 'opencode'),
|
|
// npm global
|
|
join(home, '.npm-global', 'bin', 'opencode'),
|
|
'/usr/local/bin/opencode',
|
|
// Homebrew
|
|
'/opt/homebrew/bin/opencode',
|
|
join(home, '.local', 'bin', 'opencode'),
|
|
]
|
|
for (const c of candidates) {
|
|
if (c && existsSync(c)) return c
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
/** Connect to OpenCode and fetch its configured providers/models. */
|
|
async function connectOpenCode(): Promise<ConnectResult> {
|
|
try {
|
|
const binaryPath = await resolveOpencodeBinary()
|
|
if (!binaryPath) {
|
|
return { connected: false, models: [], notInstalled: true, error: 'OpenCode CLI not found' }
|
|
}
|
|
|
|
const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client')
|
|
const { client, server } = await getOpencodeClient()
|
|
|
|
const { data, error } = await client.config.providers()
|
|
releaseOpencodeServer(server)
|
|
|
|
if (error) {
|
|
return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.' }
|
|
}
|
|
|
|
const models: GroupedModel[] = []
|
|
for (const provider of data?.providers ?? []) {
|
|
if (!provider.models) continue
|
|
for (const [, model] of Object.entries(provider.models)) {
|
|
models.push({
|
|
value: `${provider.id}/${model.id}`,
|
|
displayName: model.name || model.id,
|
|
description: `via ${provider.name || provider.id}`,
|
|
provider: 'opencode' as const,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (models.length === 0) {
|
|
return { connected: false, models: [], error: 'No models configured in OpenCode. Run "opencode" to set up providers.' }
|
|
}
|
|
|
|
return { connected: true, models }
|
|
} catch (error) {
|
|
const raw = error instanceof Error ? error.message : 'Failed to connect'
|
|
return { connected: false, models: [], error: friendlyOpenCodeError(raw) }
|
|
}
|
|
}
|
|
|
|
/** Connect to GitHub Copilot CLI via @github/copilot-sdk and fetch available models. */
|
|
async function connectCopilot(): Promise<ConnectResult> {
|
|
// Use standalone copilot binary to avoid Bun's node:sqlite issue
|
|
const { resolveCopilotCli } = await import('../../utils/copilot-client')
|
|
const cliPath = resolveCopilotCli()
|
|
if (!cliPath) {
|
|
return { connected: false, models: [], notInstalled: true, error: 'GitHub Copilot CLI not found' }
|
|
}
|
|
|
|
try {
|
|
const { CopilotClient } = await import('@github/copilot-sdk')
|
|
const client = new CopilotClient({ autoStart: true, cliPath })
|
|
|
|
await client.start()
|
|
|
|
let models: GroupedModel[] = []
|
|
try {
|
|
const modelList = await client.listModels()
|
|
models = modelList
|
|
.filter((m) => !m.policy || m.policy.state === 'enabled')
|
|
.map((m) => ({
|
|
value: m.id,
|
|
displayName: m.name,
|
|
description: m.capabilities?.supports?.vision ? 'vision' : '',
|
|
provider: 'copilot' as const,
|
|
}))
|
|
} catch (listErr) {
|
|
const msg = listErr instanceof Error ? listErr.message : 'Failed to list models'
|
|
await client.stop().catch(() => {})
|
|
return { connected: false, models: [], error: friendlyCopilotError(msg) }
|
|
}
|
|
|
|
await client.stop()
|
|
|
|
if (models.length === 0) {
|
|
return { connected: false, models: [], error: 'No models found. Run "copilot login" to authenticate first.' }
|
|
}
|
|
|
|
return { connected: true, models }
|
|
} catch (error) {
|
|
const raw = error instanceof Error ? error.message : 'Failed to connect'
|
|
return { connected: false, models: [], error: friendlyCopilotError(raw) }
|
|
}
|
|
}
|
|
|
|
/** Map Copilot SDK errors to user-friendly messages */
|
|
function friendlyCopilotError(raw: string): string {
|
|
if (/not found|ENOENT/i.test(raw)) {
|
|
return 'GitHub Copilot CLI not found. Install it from https://docs.github.com/copilot/how-tos/copilot-cli'
|
|
}
|
|
if (/not authenticated|authenticate first|auth|unauthenticated|login/i.test(raw)) {
|
|
return 'Not authenticated. Run "copilot login" in your terminal first.'
|
|
}
|
|
if (/timed?\s*out/i.test(raw)) {
|
|
return 'Connection timed out. Please try again.'
|
|
}
|
|
return raw
|
|
}
|
|
|
|
/** Map OpenCode connection errors to user-friendly messages */
|
|
function friendlyOpenCodeError(raw: string): string {
|
|
if (/ECONNREFUSED/i.test(raw)) {
|
|
return 'OpenCode server not running. Start it with "opencode" in your terminal first.'
|
|
}
|
|
if (/not found|ENOENT/i.test(raw)) {
|
|
return 'OpenCode CLI not found. Please install it first.'
|
|
}
|
|
if (/timed?\s*out/i.test(raw)) {
|
|
return 'Connection timed out. Please try again.'
|
|
}
|
|
return raw
|
|
}
|