feat(ai): add model selection and fetching capabilities
Enhance AI chat and generation functionalities by introducing model selection. Update the chat and generation endpoints to accept an optional model parameter. Implement a new API endpoint to fetch available AI models from the server, and integrate model selection into the AI chat panel for improved user experience.
This commit is contained in:
parent
22bcd8a713
commit
f81d6bde18
|
|
@ -3,6 +3,7 @@ import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
|||
interface ChatBody {
|
||||
system: string
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -27,16 +28,16 @@ export default defineEventHandler(async (event) => {
|
|||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (apiKey) {
|
||||
try {
|
||||
return await streamViaAnthropicSDK(apiKey, body)
|
||||
return await streamViaAnthropicSDK(apiKey, body, body.model)
|
||||
} catch {
|
||||
// SDK not installed or failed — fall back to Agent SDK
|
||||
}
|
||||
}
|
||||
return streamViaAgentSDK(body)
|
||||
return streamViaAgentSDK(body, body.model)
|
||||
})
|
||||
|
||||
/** Stream via Anthropic SDK (when API key is available) */
|
||||
async function streamViaAnthropicSDK(apiKey: string, body: ChatBody) {
|
||||
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 })
|
||||
|
|
@ -46,7 +47,7 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody) {
|
|||
const encoder = new TextEncoder()
|
||||
try {
|
||||
const messageStream = client.messages.stream({
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
model: model || 'claude-sonnet-4-5-20250929',
|
||||
max_tokens: 16384,
|
||||
system: body.system,
|
||||
messages: body.messages,
|
||||
|
|
@ -80,7 +81,7 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody) {
|
|||
}
|
||||
|
||||
/** Stream via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */
|
||||
function streamViaAgentSDK(body: ChatBody) {
|
||||
function streamViaAgentSDK(body: ChatBody, model?: string) {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
|
|
@ -100,7 +101,7 @@ function streamViaAgentSDK(body: ChatBody) {
|
|||
prompt,
|
||||
options: {
|
||||
systemPrompt: body.system,
|
||||
model: 'claude-sonnet-4-6',
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
maxTurns: 1,
|
||||
includePartialMessages: true,
|
||||
tools: [],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
|||
interface GenerateBody {
|
||||
system: string
|
||||
message: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -21,22 +22,22 @@ export default defineEventHandler(async (event) => {
|
|||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (apiKey) {
|
||||
try {
|
||||
return await generateViaAnthropicSDK(apiKey, body)
|
||||
return await generateViaAnthropicSDK(apiKey, body, body.model)
|
||||
} catch {
|
||||
// SDK not installed or failed — fall back to Agent SDK
|
||||
}
|
||||
}
|
||||
return generateViaAgentSDK(body)
|
||||
return generateViaAgentSDK(body, body.model)
|
||||
})
|
||||
|
||||
/** Generate via Anthropic SDK */
|
||||
async function generateViaAnthropicSDK(apiKey: string, body: GenerateBody) {
|
||||
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({
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
model: model || 'claude-sonnet-4-5-20250929',
|
||||
max_tokens: 4096,
|
||||
system: body.system,
|
||||
messages: [{ role: 'user', content: body.message }],
|
||||
|
|
@ -51,7 +52,7 @@ async function generateViaAnthropicSDK(apiKey: string, body: GenerateBody) {
|
|||
}
|
||||
|
||||
/** Generate via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */
|
||||
async function generateViaAgentSDK(body: GenerateBody): Promise<{ text?: string; error?: string }> {
|
||||
async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> {
|
||||
try {
|
||||
const { query } = await import('@anthropic-ai/claude-agent-sdk')
|
||||
|
||||
|
|
@ -63,7 +64,7 @@ async function generateViaAgentSDK(body: GenerateBody): Promise<{ text?: string;
|
|||
prompt: body.message,
|
||||
options: {
|
||||
systemPrompt: body.system,
|
||||
model: 'claude-sonnet-4-6',
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
maxTurns: 1,
|
||||
tools: [],
|
||||
permissionMode: 'plan',
|
||||
|
|
|
|||
47
server/api/ai/models.ts
Normal file
47
server/api/ai/models.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { defineEventHandler } from 'h3'
|
||||
|
||||
interface ModelInfo {
|
||||
value: string
|
||||
displayName: string
|
||||
description: string
|
||||
}
|
||||
|
||||
let cachedModels: ModelInfo[] | null = null
|
||||
|
||||
/**
|
||||
* Returns the list of available AI models.
|
||||
* Fetches from Claude Agent SDK and caches the result.
|
||||
*/
|
||||
export default defineEventHandler(async () => {
|
||||
if (cachedModels) {
|
||||
return { models: cachedModels }
|
||||
}
|
||||
|
||||
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 models = await q.supportedModels()
|
||||
cachedModels = models
|
||||
q.close()
|
||||
|
||||
return { models }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
return { models: [], error: message }
|
||||
}
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Send, Trash2, Minus, Maximize2, Sparkles } from 'lucide-react'
|
||||
import { Send, Trash2, Minus, Maximize2, Sparkles, ChevronDown } from 'lucide-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -7,7 +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 { streamChat } from '@/services/ai/ai-service'
|
||||
import { streamChat, fetchAvailableModels } from '@/services/ai/ai-service'
|
||||
import {
|
||||
CHAT_SYSTEM_PROMPT,
|
||||
DESIGN_GENERATOR_PROMPT,
|
||||
|
|
@ -90,6 +90,7 @@ function useChatHandlers() {
|
|||
const [input, setInput] = useState('')
|
||||
const messages = useAIStore((s) => s.messages)
|
||||
const isStreaming = useAIStore((s) => s.isStreaming)
|
||||
const model = useAIStore((s) => s.model)
|
||||
const addMessage = useAIStore((s) => s.addMessage)
|
||||
const updateLastMessage = useAIStore((s) => s.updateLastMessage)
|
||||
const setStreaming = useAIStore((s) => s.setStreaming)
|
||||
|
|
@ -138,7 +139,7 @@ function useChatHandlers() {
|
|||
|
||||
let accumulated = ''
|
||||
try {
|
||||
for await (const chunk of streamChat(effectivePrompt, chatHistory)) {
|
||||
for await (const chunk of streamChat(effectivePrompt, chatHistory, model)) {
|
||||
if (chunk.type === 'text') {
|
||||
accumulated += chunk.content
|
||||
updateLastMessage(accumulated)
|
||||
|
|
@ -165,7 +166,7 @@ function useChatHandlers() {
|
|||
updateLastMessage(accumulated)
|
||||
|
||||
try {
|
||||
for await (const chunk of streamChat(effectivePrompt, retryHistory)) {
|
||||
for await (const chunk of streamChat(effectivePrompt, retryHistory, model)) {
|
||||
if (chunk.type === 'text') {
|
||||
accumulated += chunk.content
|
||||
updateLastMessage(accumulated)
|
||||
|
|
@ -192,7 +193,7 @@ function useChatHandlers() {
|
|||
return { messages: msgs }
|
||||
})
|
||||
},
|
||||
[input, isStreaming, messages, addMessage, updateLastMessage, setStreaming],
|
||||
[input, isStreaming, model, messages, addMessage, updateLastMessage, setStreaming],
|
||||
)
|
||||
|
||||
return { input, setInput, handleSend, isStreaming }
|
||||
|
|
@ -239,12 +240,48 @@ export default function AIChatPanel() {
|
|||
const isMinimized = useAIStore((s) => s.isMinimized)
|
||||
const setPanelCorner = useAIStore((s) => s.setPanelCorner)
|
||||
const toggleMinimize = useAIStore((s) => s.toggleMinimize)
|
||||
const model = useAIStore((s) => s.model)
|
||||
const setModel = useAIStore((s) => s.setModel)
|
||||
const availableModels = useAIStore((s) => s.availableModels)
|
||||
const setAvailableModels = useAIStore((s) => s.setAvailableModels)
|
||||
const isLoadingModels = useAIStore((s) => s.isLoadingModels)
|
||||
const setLoadingModels = useAIStore((s) => s.setLoadingModels)
|
||||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false)
|
||||
const { input, setInput, handleSend } = useChatHandlers()
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Fetch available models on mount
|
||||
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)
|
||||
}
|
||||
}
|
||||
setLoadingModels(false)
|
||||
})
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Close model dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!modelDropdownOpen) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
const panel = panelRef.current
|
||||
if (panel && !panel.contains(e.target as Node)) {
|
||||
setModelDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', handler)
|
||||
return () => document.removeEventListener('pointerdown', handler)
|
||||
}, [modelDropdownOpen])
|
||||
|
||||
// Auto-expand when streaming starts while minimized
|
||||
useEffect(() => {
|
||||
if (isStreaming && isMinimized) {
|
||||
|
|
@ -255,7 +292,7 @@ export default function AIChatPanel() {
|
|||
/* --- Drag-to-snap handlers --- */
|
||||
|
||||
const handleDragStart = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button, input, textarea')) return
|
||||
if ((e.target as HTMLElement).closest('button, input, textarea, select')) return
|
||||
|
||||
const panel = panelRef.current
|
||||
if (!panel) return
|
||||
|
|
@ -368,9 +405,42 @@ export default function AIChatPanel() {
|
|||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-purple-400" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
AI Assistant
|
||||
</span>
|
||||
{/* 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 && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AIStreamChunk } from './ai-types'
|
||||
import type { AIModelInfo } from '@/stores/ai-store'
|
||||
|
||||
/**
|
||||
* Streams a chat response from the server-side AI endpoint.
|
||||
|
|
@ -7,12 +8,13 @@ import type { AIStreamChunk } from './ai-types'
|
|||
export async function* streamChat(
|
||||
systemPrompt: string,
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
model?: string,
|
||||
): AsyncGenerator<AIStreamChunk> {
|
||||
try {
|
||||
const response = await fetch('/api/ai/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ system: systemPrompt, messages }),
|
||||
body: JSON.stringify({ system: systemPrompt, messages, model }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -79,11 +81,12 @@ export async function* streamChat(
|
|||
export async function generateCompletion(
|
||||
systemPrompt: string,
|
||||
userMessage: string,
|
||||
model?: string,
|
||||
): Promise<string> {
|
||||
const response = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ system: systemPrompt, message: userMessage }),
|
||||
body: JSON.stringify({ system: systemPrompt, message: userMessage, model }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -96,3 +99,18 @@ export async function generateCompletion(
|
|||
}
|
||||
return data.text ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches available AI models from the server.
|
||||
* The server queries Claude Agent SDK for the supported model list.
|
||||
*/
|
||||
export async function fetchAvailableModels(): Promise<AIModelInfo[]> {
|
||||
try {
|
||||
const response = await fetch('/api/ai/models')
|
||||
if (!response.ok) return []
|
||||
const data = await response.json()
|
||||
return data.models ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import type { ChatMessage } from '@/services/ai/ai-types'
|
|||
|
||||
export type PanelCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
||||
|
||||
export interface AIModelInfo {
|
||||
value: string
|
||||
displayName: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface AIState {
|
||||
messages: ChatMessage[]
|
||||
isStreaming: boolean
|
||||
|
|
@ -10,9 +16,15 @@ interface AIState {
|
|||
activeTab: 'chat' | 'code'
|
||||
generatedCode: string
|
||||
codeFormat: 'react-tailwind' | 'html-css' | 'react-inline'
|
||||
model: string
|
||||
availableModels: AIModelInfo[]
|
||||
isLoadingModels: boolean
|
||||
panelCorner: PanelCorner
|
||||
isMinimized: boolean
|
||||
|
||||
setModel: (model: string) => void
|
||||
setAvailableModels: (models: AIModelInfo[]) => void
|
||||
setLoadingModels: (v: boolean) => void
|
||||
addMessage: (msg: ChatMessage) => void
|
||||
updateLastMessage: (content: string) => void
|
||||
setStreaming: (v: boolean) => void
|
||||
|
|
@ -33,7 +45,10 @@ export const useAIStore = create<AIState>((set) => ({
|
|||
activeTab: 'chat',
|
||||
generatedCode: '',
|
||||
codeFormat: 'react-tailwind',
|
||||
panelCorner: 'bottom-right',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
availableModels: [],
|
||||
isLoadingModels: false,
|
||||
panelCorner: 'bottom-left',
|
||||
isMinimized: false,
|
||||
|
||||
addMessage: (msg) =>
|
||||
|
|
@ -61,6 +76,9 @@ export const useAIStore = create<AIState>((set) => ({
|
|||
|
||||
setCodeFormat: (codeFormat) => set({ codeFormat }),
|
||||
|
||||
setModel: (model) => set({ model }),
|
||||
setAvailableModels: (availableModels) => set({ availableModels }),
|
||||
setLoadingModels: (isLoadingModels) => set({ isLoadingModels }),
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
|
||||
setPanelCorner: (panelCorner) => set({ panelCorner }),
|
||||
|
|
|
|||
Loading…
Reference in a new issue