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
This commit is contained in:
Fini 2026-02-19 03:15:36 +08:00
parent afc73fc226
commit 2e61c5e640
20 changed files with 905 additions and 113 deletions

View file

@ -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=="],

View file

@ -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",

10
public/logo-claude.svg Normal file
View file

@ -0,0 +1,10 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_740_1817)">
<path d="M19.7986 25.3287L27.6214 29.7179L27.753 30.0991L27.6214 30.3112H27.2402L25.9328 30.3917L21.463 30.5126L17.5865 30.6736L13.8308 30.875L12.8858 31.0763L11.9999 32.2441L12.0912 32.8266L12.8858 33.3609L14.0241 33.2615L16.5395 33.0897L20.314 32.8293L23.0523 32.6683L27.1087 32.2468H27.753L27.8442 32.5072L27.6241 32.6683L27.4523 32.8293L23.5462 35.4763L19.3181 38.2736L17.1033 39.8844L15.906 40.7005L15.302 41.4656L15.0415 43.1354L16.1288 44.3327L17.5892 44.2334L17.9624 44.134L19.4415 42.9958L22.6013 40.5501L26.7275 37.5112L27.3315 37.0092L27.5731 37.181L27.6026 37.3018L27.3315 37.7555L25.0872 41.8119L22.6926 45.9381L21.6268 47.6481L21.3449 48.6736C21.2456 49.0951 21.1731 49.4495 21.1731 49.8817L22.4107 51.5622L23.0952 51.7823L24.7462 51.5622L25.4415 50.9582L26.4671 48.6119L28.1288 44.9179L30.706 39.8951L31.4603 38.4052L31.863 37.0253L32.0134 36.6038H32.2738V36.8454L32.4858 39.675L32.8778 43.1488L33.259 47.6186L33.3905 48.8776L34.0134 50.3864L35.2509 51.2025L36.2174 50.7407L37.012 49.6025L36.902 48.8669L36.4295 45.7958L35.5033 40.985L34.8993 37.7636H35.2509L35.6536 38.1662L37.2832 40.33L40.0214 43.7528L41.2295 45.1112L42.6389 46.6119L43.5436 47.326H45.2536L46.5127 45.4548L45.9489 43.5219L44.1879 41.2884L42.7275 39.3958L40.6335 36.577L39.3261 34.3219L39.4469 34.1421L39.7583 34.1716L44.4885 35.1783L47.0442 35.6401L50.0939 36.1636L51.4738 35.5193L51.6241 34.8642L51.0818 33.5246L47.8201 32.7193L43.9946 31.9542L38.2979 30.6065L38.2281 30.5555L38.3087 30.4562L40.8751 30.2146L41.9731 30.1555H44.6603L49.6644 29.7823L50.9718 28.9179L51.7556 27.8602L51.6241 27.0548L49.6107 26.0293L46.8939 26.6736L40.553 28.1824L38.3785 28.7246H38.0778V28.5448L39.8899 26.773L43.2107 23.7743L47.3691 19.9085L47.5811 18.9528L47.0469 18.1985L46.4832 18.279L42.8295 21.028L41.4201 22.2656L38.2281 24.9528H38.016V24.6709L38.7516 23.5944L42.6362 17.7555L42.8375 15.9649L42.5556 15.3824L41.5489 15.0307L40.4429 15.232L38.1691 18.424L35.8228 22.0186L33.9301 25.2401L33.6993 25.1085L32.5825 13.079L32.059 12.4642L30.8509 12.0025L29.8442 12.7676L29.31 14.0052L29.8442 16.4508L30.4885 19.6428L31.012 22.1797L31.4845 25.3313L31.7664 26.3783L31.7476 26.4481L31.5167 26.4186L29.1409 23.1568L25.5275 18.2736L22.6684 15.2132L21.9838 14.9421L20.7973 15.5568L20.9073 16.6548L21.5704 17.632L25.5275 22.6656L27.914 25.785L29.455 27.5864L29.4442 27.8468H29.353L18.8429 21.0226L16.9718 20.781L16.1664 21.5354L16.2657 22.773L16.6469 23.1756L19.8067 25.3501L19.7959 25.3394L19.7986 25.3287Z" fill="#3D3D3D"/>
</g>
<defs>
<clipPath id="clip0_740_1817">
<rect width="40" height="40" fill="white" transform="matrix(1 0 0 -1 12 52)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

10
public/logo-openai.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -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 })

View file

@ -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<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()
}
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 = { ...process.env } as Record<string, string | undefined>
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<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 }
}
}

View file

@ -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 }

View file

@ -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) {

View file

@ -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 (
<TooltipProvider delayDuration={300}>
<div className="h-screen flex flex-col bg-background">
@ -101,6 +115,7 @@ export default function EditorLayout() {
</div>
<ExportDialog open={exportOpen} onClose={closeExport} />
<SaveDialog open={saveDialogOpen} onClose={closeSaveDialog} />
<AgentSettingsDialog />
</div>
</TooltipProvider>
)

View file

@ -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 */}
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => useAgentSettingsStore.getState().setDialogOpen(true)}
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ClaudeLogo className="w-4 h-4" />
<OpenAILogo className="w-4 h-4 -ml-1" />
<span className="hidden sm:inline">Agents & MCP</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Setup Agents & MCP</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={toggleTheme}>

View file

@ -0,0 +1,29 @@
import type { SVGProps } from 'react'
export default function ClaudeLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#cl-clip)">
<path
d="M19.7986 25.3287L27.6214 29.7179L27.753 30.0991L27.6214 30.3112H27.2402L25.9328 30.3917L21.463 30.5126L17.5865 30.6736L13.8308 30.875L12.8858 31.0763L11.9999 32.2441L12.0912 32.8266L12.8858 33.3609L14.0241 33.2615L16.5395 33.0897L20.314 32.8293L23.0523 32.6683L27.1087 32.2468H27.753L27.8442 32.5072L27.6241 32.6683L27.4523 32.8293L23.5462 35.4763L19.3181 38.2736L17.1033 39.8844L15.906 40.7005L15.302 41.4656L15.0415 43.1354L16.1288 44.3327L17.5892 44.2334L17.9624 44.134L19.4415 42.9958L22.6013 40.5501L26.7275 37.5112L27.3315 37.0092L27.5731 37.181L27.6026 37.3018L27.3315 37.7555L25.0872 41.8119L22.6926 45.9381L21.6268 47.6481L21.3449 48.6736C21.2456 49.0951 21.1731 49.4495 21.1731 49.8817L22.4107 51.5622L23.0952 51.7823L24.7462 51.5622L25.4415 50.9582L26.4671 48.6119L28.1288 44.9179L30.706 39.8951L31.4603 38.4052L31.863 37.0253L32.0134 36.6038H32.2738V36.8454L32.4858 39.675L32.8778 43.1488L33.259 47.6186L33.3905 48.8776L34.0134 50.3864L35.2509 51.2025L36.2174 50.7407L37.012 49.6025L36.902 48.8669L36.4295 45.7958L35.5033 40.985L34.8993 37.7636H35.2509L35.6536 38.1662L37.2832 40.33L40.0214 43.7528L41.2295 45.1112L42.6389 46.6119L43.5436 47.326H45.2536L46.5127 45.4548L45.9489 43.5219L44.1879 41.2884L42.7275 39.3958L40.6335 36.577L39.3261 34.3219L39.4469 34.1421L39.7583 34.1716L44.4885 35.1783L47.0442 35.6401L50.0939 36.1636L51.4738 35.5193L51.6241 34.8642L51.0818 33.5246L47.8201 32.7193L43.9946 31.9542L38.2979 30.6065L38.2281 30.5555L38.3087 30.4562L40.8751 30.2146L41.9731 30.1555H44.6603L49.6644 29.7823L50.9718 28.9179L51.7556 27.8602L51.6241 27.0548L49.6107 26.0293L46.8939 26.6736L40.553 28.1824L38.3785 28.7246H38.0778V28.5448L39.8899 26.773L43.2107 23.7743L47.3691 19.9085L47.5811 18.9528L47.0469 18.1985L46.4832 18.279L42.8295 21.028L41.4201 22.2656L38.2281 24.9528H38.016V24.6709L38.7516 23.5944L42.6362 17.7555L42.8375 15.9649L42.5556 15.3824L41.5489 15.0307L40.4429 15.232L38.1691 18.424L35.8228 22.0186L33.9301 25.2401L33.6993 25.1085L32.5825 13.079L32.059 12.4642L30.8509 12.0025L29.8442 12.7676L29.31 14.0052L29.8442 16.4508L30.4885 19.6428L31.012 22.1797L31.4845 25.3313L31.7664 26.3783L31.7476 26.4481L31.5167 26.4186L29.1409 23.1568L25.5275 18.2736L22.6684 15.2132L21.9838 14.9421L20.7973 15.5568L20.9073 16.6548L21.5704 17.632L25.5275 22.6656L27.914 25.785L29.455 27.5864L29.4442 27.8468H29.353L18.8429 21.0226L16.9718 20.781L16.1664 21.5354L16.2657 22.773L16.6469 23.1756L19.8067 25.3501L19.7959 25.3394L19.7986 25.3287Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="cl-clip">
<rect
width="40"
height="40"
fill="white"
transform="matrix(1 0 0 -1 12 52)"
/>
</clipPath>
</defs>
</svg>
)
}

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Send, Trash2, Minus, Maximize2, Sparkles, ChevronDown } from 'lucide-react'
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare } from 'lucide-react'
import { nanoid } from 'nanoid'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@ -7,6 +7,7 @@ import { useAIStore } from '@/stores/ai-store'
import type { PanelCorner } from '@/stores/ai-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import { streamChat, fetchAvailableModels } from '@/services/ai/ai-service'
import {
CHAT_SYSTEM_PROMPT,
@ -14,23 +15,31 @@ import {
} from '@/services/ai/ai-prompts'
import { extractAndApplyDesign } from '@/services/ai/design-generator'
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
import type { AIProviderType } from '@/types/agent-settings'
import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
import ChatMessage from './chat-message'
const PROVIDER_ICON: Record<AIProviderType, typeof ClaudeLogo> = {
anthropic: ClaudeLogo,
openai: OpenAILogo,
}
const QUICK_ACTIONS = [
{
label: '生成登录页',
label: 'Design a mobile login screen',
prompt: 'Design a modern mobile login screen with email input, password input, login button, and social login options',
},
{
label: '生成卡片',
label: 'Create a product card component',
prompt: 'Create a product card with an image placeholder, title, price, and buy button',
},
{
label: '生成导航栏',
label: 'Design a bottom navigation bar',
prompt: 'Design a mobile app bottom navigation bar with 5 tabs: Home, Search, Add, Messages, Profile',
},
{
label: '配色建议',
label: 'Suggest a color palette for my app',
prompt: 'Suggest a modern color palette for a pet care app',
},
]
@ -213,11 +222,11 @@ export function AIChatMinimizedBar() {
<button
type="button"
onClick={toggleMinimize}
className="h-7 bg-card border border-border rounded-lg flex items-center gap-1.5 px-3 shadow-lg hover:bg-accent transition-colors"
className="h-8 bg-card border border-border rounded-lg flex items-center gap-1.5 px-3 shadow-lg hover:bg-accent transition-colors"
>
<Sparkles size={12} className="text-purple-400" />
<span className="text-xs text-muted-foreground">AI Assistant</span>
<Maximize2 size={12} className="text-muted-foreground" />
<MessageSquare size={13} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">New Chat</span>
<ChevronUp size={12} className="text-muted-foreground" />
</button>
)
}
@ -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<AIProviderType, string> = {
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() {
<div
ref={panelRef}
className={cn(
'absolute z-50 w-[360px] rounded-xl shadow-2xl border border-border bg-card/95 backdrop-blur-sm flex flex-col overflow-hidden',
'absolute z-50 w-[320px] rounded-xl shadow-2xl border border-border bg-card/95 backdrop-blur-sm flex flex-col',
!dragStyle && CORNER_CLASSES[panelCorner],
)}
style={dragStyle ?? undefined}
>
{/* --- Header (draggable) --- */}
<div
className="flex items-center justify-between px-3 py-2 border-b border-border cursor-grab active:cursor-grabbing select-none"
className="flex items-center justify-between px-1 py-1 border-b border-border cursor-grab active:cursor-grabbing select-none"
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
>
<div className="flex items-center gap-2">
<Sparkles size={14} className="text-purple-400" />
{/* Model selector */}
<div className="relative">
<button
type="button"
onClick={() => setModelDropdownOpen((v) => !v)}
disabled={isLoadingModels || availableModels.length === 0}
className="flex items-center gap-1 text-xs font-medium text-foreground hover:text-accent-foreground transition-colors"
>
<span className="max-w-[120px] truncate">
{availableModels.find((m) => m.value === model)?.displayName ?? model}
</span>
<ChevronDown size={10} className="text-muted-foreground shrink-0" />
</button>
{modelDropdownOpen && availableModels.length > 0 && (
<div className="absolute top-full left-0 mt-1 z-[60] w-56 rounded-lg border border-border bg-card shadow-xl py-1 max-h-60 overflow-y-auto">
{availableModels.map((m) => (
<button
key={m.value}
type="button"
onClick={() => {
setModel(m.value)
setModelDropdownOpen(false)
}}
className={cn(
'w-full text-left px-3 py-1.5 text-xs transition-colors',
m.value === model
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<span className="font-medium">{m.displayName}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1">
{messages.length > 0 && (
<Button
variant="ghost"
size="icon-sm"
onClick={clearMessages}
title="Clear chat"
>
<Trash2 size={12} />
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={toggleMinimize}
title="Minimize"
title="Collapse"
>
<Minus size={12} />
<ChevronDown size={14} />
</Button>
<span className="text-sm font-medium text-foreground">New Chat</span>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={clearMessages}
title="New chat"
>
<Plus size={14} />
</Button>
</div>
{/* --- Messages --- */}
<div className="overflow-y-auto p-3 max-h-[350px]">
<div className="flex-1 overflow-y-auto px-3.5 py-3 max-h-[350px] bg-background/80 rounded-b-xl">
{messages.length === 0 ? (
<div className="text-center mt-4">
<div className="text-2xl mb-2">🎨</div>
<p className="text-sm text-foreground mb-1 font-medium">
Design with AI
</p>
<div className="flex flex-col items-center justify-center py-4">
<p className="text-xs text-muted-foreground mb-4">
Describe any UI and it appears on canvas
Try an example to design...
</p>
<div className="flex flex-wrap gap-2 justify-center">
<div className="flex flex-col gap-2 w-full px-2">
{QUICK_ACTIONS.map((action) => (
<button
key={action.label}
type="button"
onClick={() => handleSend(action.prompt)}
className="text-xs px-2.5 py-1.5 rounded-full bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
className="text-xs text-left px-3.5 py-1 rounded-full bg-secondary/50 border border-border text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
>
{action.label}
</button>
))}
</div>
<p className="text-[10px] text-muted-foreground/50 mt-5">
Tip: Select elements on canvas before chatting for context.
</p>
</div>
) : (
messages.map((msg) => (
@ -502,30 +505,138 @@ export default function AIChatPanel() {
<div ref={messagesEndRef} />
</div>
{/* --- Input --- */}
<div className="p-2 border-t border-border">
<div className="flex items-end gap-1.5 bg-background/50 rounded-lg border border-input focus-within:border-ring transition-colors">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isStreaming ? 'Generating...' : 'Ask AI anything...'}
disabled={isStreaming}
rows={1}
className="flex-1 bg-transparent text-sm text-foreground placeholder-muted-foreground px-3 py-2 resize-none outline-none max-h-24 min-h-[36px]"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleSend()}
disabled={isStreaming || !input.trim()}
title="Send message"
className="shrink-0 m-1"
{/* --- Input area --- */}
<div className="relative border-t border-border bg-card rounded-b-xl">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isStreaming ? 'Generating...' : 'Design with Claude or Codex...'}
disabled={isStreaming}
rows={2}
className="w-full bg-transparent text-sm text-foreground placeholder-muted-foreground px-3.5 pt-3 pb-2 resize-none outline-none max-h-28 min-h-[52px]"
/>
{/* --- Bottom bar: model selector + actions --- */}
<div className="flex items-center justify-between px-2 pb-2">
{/* Model selector */}
<button
type="button"
onClick={() => setModelDropdownOpen((v) => !v)}
disabled={isLoadingModels || availableModels.length === 0}
className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors px-1.5 py-1 rounded-md hover:bg-secondary"
>
<Send size={14} />
</Button>
{(() => {
const currentProvider = modelGroups.find((g) =>
g.models.some((m) => m.value === model),
)?.provider
if (currentProvider) {
const ProvIcon = PROVIDER_ICON[currentProvider]
return <ProvIcon className="w-3.5 h-3.5 shrink-0" />
}
return null
})()}
<span className="truncate max-w-[160px]">
{isLoadingModels
? 'Loading models...'
: availableModels.find((m) => m.value === model)?.displayName ?? model}
</span>
<ChevronUp size={10} className="shrink-0" />
</button>
{/* Action icons */}
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleSend()}
disabled={isStreaming || !input.trim()}
title="Send message"
className={cn(
'shrink-0 rounded-lg h-7 w-7',
input.trim() && !isStreaming
? 'bg-foreground text-background hover:bg-foreground/90'
: '',
)}
>
<Send size={13} />
</Button>
</div>
</div>
{/* Upward model dropdown */}
{modelDropdownOpen && availableModels.length > 0 && (
<div className="absolute bottom-full left-2 right-2 mb-1 z-[60] rounded-lg border border-border bg-card shadow-xl py-1 max-h-72 overflow-y-auto">
{modelGroups.length > 0
? modelGroups.map((group) => {
const GIcon = PROVIDER_ICON[group.provider]
return (
<div key={group.provider}>
<div className="flex items-center gap-1.5 px-3 pt-2.5 pb-1">
<GIcon className="w-3 h-3 text-muted-foreground" />
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
{group.providerName}
</span>
</div>
{group.models.map((m, idx) => {
const isSelected = m.value === model
return (
<button
key={m.value}
type="button"
onClick={() => {
setModel(m.value)
setModelDropdownOpen(false)
}}
className={cn(
'w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors',
isSelected
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<span className="w-3.5 shrink-0">
{isSelected && <Check size={12} />}
</span>
<span className="font-medium">{m.displayName}</span>
{idx === 0 && (
<span className="text-[9px] text-muted-foreground bg-secondary px-1 py-0.5 rounded ml-auto">
Best
</span>
)}
</button>
)
})}
</div>
)
})
: availableModels.map((m) => {
const isSelected = m.value === model
return (
<button
key={m.value}
type="button"
onClick={() => {
setModel(m.value)
setModelDropdownOpen(false)
}}
className={cn(
'w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors',
isSelected
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<span className="w-3.5 shrink-0">
{isSelected && <Check size={12} />}
</span>
<span className="font-medium">{m.displayName}</span>
</button>
)
})}
</div>
)}
</div>
</div>
)

View file

@ -346,7 +346,7 @@ export default function ChatMessage({
<div className="text-sm leading-relaxed text-foreground">
{/* Streaming with no content yet → thinking indicator */}
{isEmpty && isStreaming ? (
<div className="flex items-center gap-1.5 bg-secondary/50 rounded-full w-fit py-1 px-2.5">
<div className="flex items-center gap-1.5 bg-secondary/50 rounded-full w-fit py-1 px-2.5 mt-2">
<span className="text-xs text-muted-foreground">Thinking</span>
<span className="flex gap-0.5">
<span className="w-1 h-1 rounded-full bg-muted-foreground/70 animate-bounce" style={{ animationDelay: '0ms' }} />

View file

@ -0,0 +1,233 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type { ComponentType, SVGProps } from 'react'
import { X, Check, Loader2, Unplug } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import type { AIProviderType, GroupedModel } from '@/types/agent-settings'
import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
/** Provider display metadata */
const PROVIDER_META: Record<
AIProviderType,
{ label: string; description: string; agent: 'claude-code' | 'codex-cli'; Icon: ComponentType<SVGProps<SVGSVGElement>> }
> = {
anthropic: {
label: 'Claude Code',
description: 'Connect to local Claude Code CLI to use Claude models',
agent: 'claude-code',
Icon: ClaudeLogo,
},
openai: {
label: 'Codex CLI',
description: 'Connect to local Codex CLI to use OpenAI models',
agent: 'codex-cli',
Icon: OpenAILogo,
},
}
async function connectAgent(
agent: 'claude-code' | 'codex-cli',
): Promise<{ connected: boolean; models: GroupedModel[]; error?: string }> {
try {
const res = await fetch('/api/ai/connect-agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent }),
})
if (!res.ok) return { connected: false, models: [], error: `Server error ${res.status}` }
return await res.json()
} catch {
return { connected: false, models: [], error: 'Connection failed' }
}
}
function ProviderCard({ type }: { type: AIProviderType }) {
const provider = useAgentSettingsStore((s) => s.providers[type])
const connect = useAgentSettingsStore((s) => s.connectProvider)
const disconnect = useAgentSettingsStore((s) => s.disconnectProvider)
const persist = useAgentSettingsStore((s) => s.persist)
const [isConnecting, setIsConnecting] = useState(false)
const [error, setError] = useState<string | null>(null)
const meta = PROVIDER_META[type]
const handleConnect = useCallback(async () => {
setIsConnecting(true)
setError(null)
const result = await connectAgent(meta.agent)
if (result.connected) {
connect(type, meta.agent, result.models)
persist()
} else {
setError(result.error ?? 'Connection failed')
}
setIsConnecting(false)
}, [type, meta.agent, connect, persist])
const handleDisconnect = useCallback(() => {
disconnect(type)
setError(null)
persist()
}, [type, disconnect, persist])
const { Icon } = meta
return (
<div className="rounded-lg border border-border bg-background/50 p-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-8 h-8 rounded-md bg-secondary flex items-center justify-center text-foreground">
<Icon className="w-5 h-5" />
</div>
<span className="text-sm font-medium text-foreground">{meta.label}</span>
</div>
<p className="text-[11px] text-muted-foreground mb-2.5">{meta.description}</p>
{provider.isConnected ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Check size={13} className="text-green-500" />
<span className="text-xs text-green-500">
Connected — {provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleDisconnect}
className="h-7 text-xs text-muted-foreground hover:text-destructive"
>
<Unplug size={12} className="mr-1" />
Disconnect
</Button>
</div>
) : (
<>
<Button
size="sm"
onClick={handleConnect}
disabled={isConnecting}
className="h-7 text-xs"
>
{isConnecting ? (
<>
<Loader2 size={12} className="animate-spin mr-1" />
Connecting...
</>
) : (
'Connect'
)}
</Button>
{error && (
<p className="text-xs text-destructive mt-1.5">{error}</p>
)}
</>
)}
</div>
)
}
export default function AgentSettingsDialog() {
const open = useAgentSettingsStore((s) => s.dialogOpen)
const setDialogOpen = useAgentSettingsStore((s) => s.setDialogOpen)
const mcpIntegrations = useAgentSettingsStore((s) => s.mcpIntegrations)
const toggleMCP = useAgentSettingsStore((s) => s.toggleMCPIntegration)
const persist = useAgentSettingsStore((s) => s.persist)
const dialogRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setDialogOpen(false)
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open, setDialogOpen])
const handleToggleMCP = useCallback(
(tool: string) => {
toggleMCP(tool)
persist()
},
[toggleMCP, persist],
)
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-background/80"
onClick={() => setDialogOpen(false)}
/>
<div
ref={dialogRef}
className="relative bg-card rounded-lg border border-border p-5 w-[420px] max-h-[80vh] overflow-y-auto shadow-xl"
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-foreground">
Setup Agents & MCP
</h3>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDialogOpen(false)}
>
<X size={14} />
</Button>
</div>
{/* Agents section */}
<div className="mb-5">
<div className="flex items-center mb-2">
<h4 className="text-xs font-medium text-foreground">
Agents on Canvas
</h4>
<span className="text-[10px] text-muted-foreground bg-secondary px-1.5 py-0.5 ml-2 rounded">
Recommended
</span>
</div>
<div className="space-y-2">
<ProviderCard type="anthropic" />
<ProviderCard type="openai" />
</div>
</div>
{/* MCP integrations section */}
<div>
<h4 className="text-xs font-medium text-foreground mb-2">
MCP Integrations in Terminal
</h4>
<div className="space-y-1">
{mcpIntegrations.map((m) => (
<div
key={m.tool}
className="flex items-center justify-between py-2 px-1"
>
<span
className={cn(
'text-xs',
m.enabled ? 'text-foreground' : 'text-muted-foreground',
)}
>
{m.displayName}
</span>
<Switch
checked={m.enabled}
onCheckedChange={() => handleToggleMCP(m.tool)}
/>
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-2">
MCP integrations will take effect after restarting the terminal.
</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,26 @@
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -3,7 +3,7 @@ import type { AIModelInfo } from '@/stores/ai-store'
/**
* Streams a chat response from the server-side AI endpoint.
* The server uses ANTHROPIC_API_KEY from the environment (no client-side key needed).
* The server uses ANTHROPIC_API_KEY or local Agent SDK (no client-side key needed).
*/
export async function* streamChat(
systemPrompt: string,
@ -114,3 +114,4 @@ export async function fetchAvailableModels(): Promise<AIModelInfo[]> {
return []
}
}

View file

@ -0,0 +1,124 @@
import { create } from 'zustand'
import type {
AIProviderType,
AIProviderConfig,
MCPCliIntegration,
GroupedModel,
} from '@/types/agent-settings'
const STORAGE_KEY = 'openpencil-agent-settings'
interface PersistedState {
providers: Record<AIProviderType, AIProviderConfig>
mcpIntegrations: MCPCliIntegration[]
}
interface AgentSettingsState extends PersistedState {
dialogOpen: boolean
connectProvider: (
provider: AIProviderType,
method: AIProviderConfig['connectionMethod'],
models: GroupedModel[],
) => void
disconnectProvider: (provider: AIProviderType) => void
toggleMCPIntegration: (tool: string) => void
setDialogOpen: (open: boolean) => void
persist: () => void
hydrate: () => void
}
const DEFAULT_PROVIDERS: Record<AIProviderType, AIProviderConfig> = {
anthropic: {
type: 'anthropic',
displayName: 'Claude Code',
isConnected: false,
connectionMethod: null,
models: [],
},
openai: {
type: 'openai',
displayName: 'Codex CLI',
isConnected: false,
connectionMethod: null,
models: [],
},
}
const DEFAULT_MCP_INTEGRATIONS: MCPCliIntegration[] = [
{ tool: 'claude-code', displayName: 'Claude Code CLI', enabled: false, installed: false },
{ tool: 'codex-cli', displayName: 'Codex CLI', enabled: false, installed: false },
{ tool: 'gemini-cli', displayName: 'Gemini CLI', enabled: false, installed: false },
{ tool: 'opencode-cli', displayName: 'OpenCode CLI', enabled: false, installed: false },
{ tool: 'kiro-cli', displayName: 'Kiro CLI', enabled: false, installed: false },
]
export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
providers: { ...DEFAULT_PROVIDERS },
mcpIntegrations: [...DEFAULT_MCP_INTEGRATIONS],
dialogOpen: false,
connectProvider: (provider, method, models) =>
set((s) => ({
providers: {
...s.providers,
[provider]: {
...s.providers[provider],
isConnected: true,
connectionMethod: method,
models,
},
},
})),
disconnectProvider: (provider) =>
set((s) => ({
providers: {
...s.providers,
[provider]: {
...DEFAULT_PROVIDERS[provider],
},
},
})),
toggleMCPIntegration: (tool) =>
set((s) => ({
mcpIntegrations: s.mcpIntegrations.map((m) =>
m.tool === tool ? { ...m, enabled: !m.enabled } : m,
),
})),
setDialogOpen: (dialogOpen) => set({ dialogOpen }),
persist: () => {
try {
const { providers, mcpIntegrations } = get()
localStorage.setItem(STORAGE_KEY, JSON.stringify({ providers, mcpIntegrations }))
} catch {
// ignore
}
},
hydrate: () => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const data = JSON.parse(raw) as Partial<PersistedState>
if (data.providers) {
// Merge with defaults to ensure new fields (e.g. models) exist
const merged = { ...DEFAULT_PROVIDERS }
for (const key of Object.keys(merged) as AIProviderType[]) {
if (data.providers[key]) {
merged[key] = { ...merged[key], ...data.providers[key] }
// Ensure models array always exists
if (!Array.isArray(merged[key].models)) merged[key].models = []
}
}
set({ providers: merged })
}
if (data.mcpIntegrations) set({ mcpIntegrations: data.mcpIntegrations })
} catch {
// ignore
}
},
}))

View file

@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { ChatMessage } from '@/services/ai/ai-types'
import type { ModelGroup } from '@/types/agent-settings'
export type PanelCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
@ -18,12 +19,14 @@ interface AIState {
codeFormat: 'react-tailwind' | 'html-css' | 'react-inline'
model: string
availableModels: AIModelInfo[]
modelGroups: ModelGroup[]
isLoadingModels: boolean
panelCorner: PanelCorner
isMinimized: boolean
setModel: (model: string) => void
setAvailableModels: (models: AIModelInfo[]) => void
setModelGroups: (groups: ModelGroup[]) => void
setLoadingModels: (v: boolean) => void
addMessage: (msg: ChatMessage) => void
updateLastMessage: (content: string) => void
@ -47,6 +50,7 @@ export const useAIStore = create<AIState>((set) => ({
codeFormat: 'react-tailwind',
model: 'claude-sonnet-4-5-20250929',
availableModels: [],
modelGroups: [],
isLoadingModels: false,
panelCorner: 'bottom-left',
isMinimized: false,
@ -78,6 +82,7 @@ export const useAIStore = create<AIState>((set) => ({
setModel: (model) => set({ model }),
setAvailableModels: (availableModels) => set({ availableModels }),
setModelGroups: (modelGroups) => set({ modelGroups }),
setLoadingModels: (isLoadingModels) => set({ isLoadingModels }),
clearMessages: () => set({ messages: [] }),

View file

@ -0,0 +1,37 @@
export type AIProviderType = 'anthropic' | 'openai'
export interface AIProviderConfig {
type: AIProviderType
displayName: string
isConnected: boolean
connectionMethod: 'claude-code' | 'codex-cli' | null
/** Models fetched when the user connects this provider */
models: GroupedModel[]
}
export type MCPCliTool =
| 'claude-code'
| 'codex-cli'
| 'gemini-cli'
| 'opencode-cli'
| 'kiro-cli'
export interface MCPCliIntegration {
tool: MCPCliTool
displayName: string
enabled: boolean
installed: boolean
}
export interface GroupedModel {
value: string
displayName: string
description: string
provider: AIProviderType
}
export interface ModelGroup {
provider: AIProviderType
providerName: string
models: GroupedModel[]
}