From 2e61c5e6401edb6376471bbf2c2c3cefa4f6e9f8 Mon Sep 17 00:00:00 2001 From: Fini Date: Thu, 19 Feb 2026 03:15:36 +0800 Subject: [PATCH] feat(ai): add Setup Agents & MCP dialog with multi-provider support - Add agent settings dialog to connect local Claude Code and Codex CLI - Connect button fetches real models from Agent SDK / Codex models cache - Provider logos (Claude, OpenAI) as React icon components in top-bar and dialog - Model selector in AI chat panel grouped by provider with logos and Best badge - MCP CLI integration toggles (Claude Code, Codex, Gemini, OpenCode, Kiro) - Persist agent settings to localStorage with hydrate on mount - Redesign AI chat panel: header, empty state pills, input area, color layering - Add shadcn Switch component, agent-settings types and Zustand store --- bun.lock | 3 + package.json | 1 + public/logo-claude.svg | 10 + public/logo-openai.svg | 10 + server/api/ai/chat.ts | 1 - server/api/ai/connect-agent.ts | 139 ++++++++ server/api/ai/generate.ts | 5 +- server/api/ai/models.ts | 4 +- src/components/editor/editor-layout.tsx | 15 + src/components/editor/top-bar.tsx | 21 ++ src/components/icons/claude-logo.tsx | 29 ++ src/components/icons/openai-logo.tsx | 29 ++ src/components/panels/ai-chat-panel.tsx | 321 ++++++++++++------ src/components/panels/chat-message.tsx | 2 +- .../shared/agent-settings-dialog.tsx | 233 +++++++++++++ src/components/ui/switch.tsx | 26 ++ src/services/ai/ai-service.ts | 3 +- src/stores/agent-settings-store.ts | 124 +++++++ src/stores/ai-store.ts | 5 + src/types/agent-settings.ts | 37 ++ 20 files changed, 905 insertions(+), 113 deletions(-) create mode 100644 public/logo-claude.svg create mode 100644 public/logo-openai.svg create mode 100644 server/api/ai/connect-agent.ts create mode 100644 src/components/icons/claude-logo.tsx create mode 100644 src/components/icons/openai-logo.tsx create mode 100644 src/components/shared/agent-settings-dialog.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/stores/agent-settings-store.ts create mode 100644 src/types/agent-settings.ts diff --git a/bun.lock b/bun.lock index 23244def1..645690a2c 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", @@ -271,6 +272,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "https://registry.npmmirror.com/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], diff --git a/package.json b/package.json index 290f8a676..095d75fa4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", diff --git a/public/logo-claude.svg b/public/logo-claude.svg new file mode 100644 index 000000000..63e586ed3 --- /dev/null +++ b/public/logo-claude.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/logo-openai.svg b/public/logo-openai.svg new file mode 100644 index 000000000..34b75ffe6 --- /dev/null +++ b/public/logo-openai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/server/api/ai/chat.ts b/server/api/ai/chat.ts index 77123e1c7..4df1c1d22 100644 --- a/server/api/ai/chat.ts +++ b/server/api/ai/chat.ts @@ -38,7 +38,6 @@ export default defineEventHandler(async (event) => { /** Stream via Anthropic SDK (when API key is available) */ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: string) { - // @ts-expect-error — optional dependency, only used when ANTHROPIC_API_KEY is set const { default: Anthropic } = await import('@anthropic-ai/sdk') const client = new Anthropic({ apiKey }) diff --git a/server/api/ai/connect-agent.ts b/server/api/ai/connect-agent.ts new file mode 100644 index 000000000..1dd41b517 --- /dev/null +++ b/server/api/ai/connect-agent.ts @@ -0,0 +1,139 @@ +import { defineEventHandler, readBody, setResponseHeaders } from 'h3' +import type { GroupedModel } from '../../../src/types/agent-settings' + +interface ConnectBody { + agent: 'claude-code' | 'codex-cli' +} + +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(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() + } + + 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 { + try { + const { query } = await import('@anthropic-ai/claude-agent-sdk') + + const env = { ...process.env } as Record + delete env.CLAUDECODE + + const q = query({ + prompt: '', + options: { + model: 'claude-sonnet-4-6', + maxTurns: 1, + tools: [], + permissionMode: 'plan', + persistSession: false, + env, + }, + }) + + 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' + return { connected: false, models: [], error: msg } + } +} + +/** Connect to Codex CLI and fetch its supported models from the local cache */ +async function connectCodexCli(): Promise { + 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 } + } +} diff --git a/server/api/ai/generate.ts b/server/api/ai/generate.ts index 7a502e423..1ee270f05 100644 --- a/server/api/ai/generate.ts +++ b/server/api/ai/generate.ts @@ -33,7 +33,6 @@ export default defineEventHandler(async (event) => { /** Generate via Anthropic SDK */ async function generateViaAnthropicSDK(apiKey: string, body: GenerateBody, model?: string) { try { - // @ts-expect-error — optional dependency, only used when ANTHROPIC_API_KEY is set const { default: Anthropic } = await import('@anthropic-ai/sdk') const client = new Anthropic({ apiKey }) const response = await client.messages.create({ @@ -43,8 +42,8 @@ async function generateViaAnthropicSDK(apiKey: string, body: GenerateBody, model messages: [{ role: 'user', content: body.message }], }) - const textBlock = response.content.find((b: { type: string }) => b.type === 'text') - return { text: textBlock?.text ?? '' } + const textBlock = response.content.find((b) => b.type === 'text') + return { text: textBlock && 'text' in textBlock ? textBlock.text : '' } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' return { error: message } diff --git a/server/api/ai/models.ts b/server/api/ai/models.ts index b6ff0bb3e..cba4118ae 100644 --- a/server/api/ai/models.ts +++ b/server/api/ai/models.ts @@ -9,8 +9,8 @@ interface ModelInfo { let cachedModels: ModelInfo[] | null = null /** - * Returns the list of available AI models. - * Fetches from Claude Agent SDK and caches the result. + * Returns the list of available AI models via Claude Agent SDK. + * Used as a fallback when no providers are explicitly connected. */ export default defineEventHandler(async () => { if (cachedModels) { diff --git a/src/components/editor/editor-layout.tsx b/src/components/editor/editor-layout.tsx index 8b2df9108..8dfb27c3f 100644 --- a/src/components/editor/editor-layout.tsx +++ b/src/components/editor/editor-layout.tsx @@ -9,9 +9,11 @@ import AIChatPanel, { AIChatMinimizedBar } from '@/components/panels/ai-chat-pan import CodePanel from '@/components/panels/code-panel' import ExportDialog from '@/components/shared/export-dialog' import SaveDialog from '@/components/shared/save-dialog' +import AgentSettingsDialog from '@/components/shared/agent-settings-dialog' import { useAIStore } from '@/stores/ai-store' import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore } from '@/stores/document-store' +import { useAgentSettingsStore } from '@/stores/agent-settings-store' const FabricCanvas = lazy(() => import('@/canvas/fabric-canvas')) @@ -58,11 +60,23 @@ export default function EditorLayout() { setExportOpen((prev) => !prev) return } + + // Cmd+,: open agent settings + if (isMod && e.key === ',') { + e.preventDefault() + useAgentSettingsStore.getState().setDialogOpen(true) + return + } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [toggleMinimize, toggleCodePanel]) + // Hydrate persisted agent settings + useEffect(() => { + useAgentSettingsStore.getState().hydrate() + }, []) + return (
@@ -101,6 +115,7 @@ export default function EditorLayout() {
+
) diff --git a/src/components/editor/top-bar.tsx b/src/components/editor/top-bar.tsx index 2db4bb5d5..333c9c655 100644 --- a/src/components/editor/top-bar.tsx +++ b/src/components/editor/top-bar.tsx @@ -9,6 +9,8 @@ import { Maximize, Minimize, } from 'lucide-react' +import ClaudeLogo from '@/components/icons/claude-logo' +import OpenAILogo from '@/components/icons/openai-logo' import { Button } from '@/components/ui/button' import { Tooltip, @@ -27,6 +29,7 @@ import { } from '@/utils/file-operations' import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync' import { zoomToFitContent } from '@/canvas/use-fabric-canvas' +import { useAgentSettingsStore } from '@/stores/agent-settings-store' export default function TopBar() { const toggleLayerPanel = useCanvasStore((s) => s.toggleLayerPanel) @@ -196,6 +199,24 @@ export default function TopBar() { {/* Right section */}
+ + + + + Setup Agents & MCP + + +
+ ) } @@ -244,8 +253,11 @@ export default function AIChatPanel() { const setModel = useAIStore((s) => s.setModel) const availableModels = useAIStore((s) => s.availableModels) const setAvailableModels = useAIStore((s) => s.setAvailableModels) + const modelGroups = useAIStore((s) => s.modelGroups) + const setModelGroups = useAIStore((s) => s.setModelGroups) const isLoadingModels = useAIStore((s) => s.isLoadingModels) const setLoadingModels = useAIStore((s) => s.setLoadingModels) + const providers = useAgentSettingsStore((s) => s.providers) const [modelDropdownOpen, setModelDropdownOpen] = useState(false) const { input, setInput, handleSend } = useChatHandlers() @@ -253,21 +265,53 @@ export default function AIChatPanel() { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) - // Fetch available models on mount + // Build model list from connected providers in agent-settings-store. + // Falls back to Agent SDK legacy fetch when no providers are connected. useEffect(() => { - if (availableModels.length > 0) return - setLoadingModels(true) - fetchAvailableModels().then((models) => { - if (models.length > 0) { - setAvailableModels(models) - // Set default model if current model is not in the list - if (!models.some((m) => m.value === model)) { - setModel(models[0].value) - } + const connectedProviders = (Object.keys(providers) as AIProviderType[]).filter( + (p) => providers[p].isConnected && (providers[p].models?.length ?? 0) > 0, + ) + + if (connectedProviders.length > 0) { + // Build groups + flat list from stored models + const providerNames: Record = { + anthropic: 'Anthropic', + openai: 'OpenAI', + } + const groups = connectedProviders.map((p) => ({ + provider: p, + providerName: providerNames[p], + models: providers[p].models, + })) + const flat = groups.flatMap((g) => + g.models.map((m) => ({ + value: m.value, + displayName: m.displayName, + description: m.description, + })), + ) + setModelGroups(groups) + setAvailableModels(flat) + // If current model not in list, select first + if (!flat.some((m) => m.value === model)) { + setModel(flat[0].value) } setLoadingModels(false) - }) - }, []) // eslint-disable-line react-hooks/exhaustive-deps + } else { + // No providers connected — fall back to Agent SDK legacy model list + setModelGroups([]) + setLoadingModels(true) + fetchAvailableModels().then((models) => { + if (models.length > 0) { + setAvailableModels(models) + if (!models.some((m) => m.value === model)) { + setModel(models[0].value) + } + } + setLoadingModels(false) + }) + } + }, [providers]) // eslint-disable-line react-hooks/exhaustive-deps // Close model dropdown when clicking outside useEffect(() => { @@ -391,102 +435,61 @@ export default function AIChatPanel() {
{/* --- Header (draggable) --- */}
-
- - {/* Model selector */} -
- - {modelDropdownOpen && availableModels.length > 0 && ( -
- {availableModels.map((m) => ( - - ))} -
- )} -
-
- {messages.length > 0 && ( - - )} + New Chat
+
{/* --- Messages --- */} -
+
{messages.length === 0 ? ( -
-
🎨
-

- Design with AI -

+

- Describe any UI and it appears on canvas + Try an example to design...

-
+
{QUICK_ACTIONS.map((action) => ( ))}
+

+ Tip: Select elements on canvas before chatting for context. +

) : ( messages.map((msg) => ( @@ -502,30 +505,138 @@ export default function AIChatPanel() {
- {/* --- Input --- */} -
-
-