openpencil/server/api/ai/connect-agent.ts
Kayshen Xu 7db7a99ff2 V0.0.2 (#9)
* chore(docs): add video demo to README.md for enhanced project visibility

* refactor(ai): centralize Claude Agent SDK env resolution with debug logging and model retry

Extract shared buildClaudeAgentEnv() and getClaudeAgentDebugFilePath() into
resolve-claude-agent-env.ts to eliminate duplicated env setup across all
server endpoints. Add model fallback retry logic (retry without explicit
model on exit code 1) and diagnostic hints from debug log tail on failures.

* fix(canvas): normalize layout justify/align values from CSS aliases

AI-generated nodes may use CSS-style values like "flex-start",
"space-between", "middle" etc. Add normalizeJustifyContent and
normalizeAlignItems to map these to internal enum values in both
the layout engine and the property panel.

* fix(canvas): improve text and path rendering accuracy

- Remove icon-specific bounding box override on path scaling to prevent
  pathOffset drift that visually offsets icons in logo containers
- Only use Textbox for explicit fixed-width text modes instead of any
  node with width > 0, preventing unwanted word wrapping on auto-width text
- Add widthSafetyFactor for Latin text estimation (1.14x vs 1.06x CJK)
  to reduce accidental line wraps from font width variation

* feat(ai): add input icon affordance rules and trailing icon alignment

Prompt AI to include semantic icons in form inputs (search, password,
email). Add normalizeInputTrailingIconAlignment post-pass in
role-resolver to auto-set justifyContent="space_between" on input
frames with a trailing icon node.

* feat(ai): persist model selection across sessions via localStorage

Add preferredModel and selectModel to ai-store with localStorage
read/write. Hydrate on mount to restore user's last chosen model.
Add isHydrated flag to agent-settings-store to prevent race
conditions during provider list construction.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-02-26 09:38:48 +08:00

220 lines
6.8 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'
}
interface ConnectResult {
connected: boolean
models: GroupedModel[]
error?: string
}
/**
* 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()
}
return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult
})
/** Connect to Claude Code via Agent SDK and fetch real supported models */
async function connectClaudeCode(): Promise<ConnectResult> {
try {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
const env = buildClaudeAgentEnv()
const debugFile = getClaudeAgentDebugFilePath()
const claudePath = resolveClaudeCli()
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 raw = error instanceof Error ? error.message : 'Failed to connect'
return { connected: false, models: [], error: friendlyClaudeError(raw) }
}
}
/** 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. Check your model mapping (e.g. ANTHROPIC_MODEL / default Sonnet model) and run "claude login" if needed.'
}
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 which = execSync('which codex 2>/dev/null || echo ""', {
encoding: 'utf-8',
timeout: 5000,
}).trim()
if (!which) {
return { connected: false, models: [], error: 'codex command not found. Install Codex CLI first.' }
}
// 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 }
}
}
/** Connect to OpenCode and fetch its configured providers/models. */
async function connectOpenCode(): Promise<ConnectResult> {
try {
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) }
}
}
/** 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
}