Delete src/types.ts (pure re-export of Color, Rect, etc.) — 9 files now import directly from @open-pencil/core. Remove dead AI_PROVIDERS re-export from use-chat.ts and dead font function re-exports from engine/fonts.ts.
186 lines
5.5 KiB
TypeScript
186 lines
5.5 KiB
TypeScript
import { createAnthropic } from '@ai-sdk/anthropic'
|
|
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
|
import { createOpenAI } from '@ai-sdk/openai'
|
|
import { Chat } from '@ai-sdk/vue'
|
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
|
import { useLocalStorage } from '@vueuse/core'
|
|
import { DirectChatTransport, ToolLoopAgent } from 'ai'
|
|
import dedent from 'dedent'
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
import { createAITools } from '@/ai/tools'
|
|
import { useEditorStore } from '@/stores/editor'
|
|
import { AI_PROVIDERS, DEFAULT_AI_MODEL, DEFAULT_AI_PROVIDER } from '@open-pencil/core'
|
|
|
|
import type { AIProviderID } from '@open-pencil/core'
|
|
import type { LanguageModel, UIMessage } from 'ai'
|
|
|
|
|
|
|
|
const STORAGE_PREFIX = 'open-pencil:'
|
|
const LEGACY_KEY_STORAGE = `${STORAGE_PREFIX}openrouter-api-key`
|
|
|
|
function keyStorageKey(id: string) {
|
|
return `${STORAGE_PREFIX}ai-key:${id}`
|
|
}
|
|
|
|
function migrateLegacyStorage() {
|
|
const legacyKey = localStorage.getItem(LEGACY_KEY_STORAGE)
|
|
if (legacyKey) {
|
|
localStorage.setItem(keyStorageKey('openrouter'), legacyKey)
|
|
localStorage.removeItem(LEGACY_KEY_STORAGE)
|
|
if (!localStorage.getItem(`${STORAGE_PREFIX}ai-provider`)) {
|
|
localStorage.setItem(`${STORAGE_PREFIX}ai-provider`, 'openrouter')
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof window !== 'undefined') migrateLegacyStorage()
|
|
|
|
const SYSTEM_PROMPT = dedent`
|
|
You are a design assistant inside OpenPencil, a Figma-like design editor.
|
|
Help users create and modify designs. Be concise and direct.
|
|
When describing changes, use specific design terminology.
|
|
|
|
Use the render tool with JSX as the primary way to create designs.
|
|
JSX supports full JavaScript expressions (map, ternaries, Array.from, etc.).
|
|
Available tags: Frame, Text, Rectangle, Ellipse, Line, Star, Polygon, Group, Section.
|
|
Common props: name, w, h, x, y, bg (hex color), stroke, rounded, opacity, rotate.
|
|
Layout: flex="row"|"col", gap, justify, items, p, px, py, pt/pr/pb/pl, wrap.
|
|
Text: size, weight, color, font, textAlign.
|
|
Sizing: w/h accept numbers (px) or "hug"/"fill".
|
|
|
|
Colors are hex strings (#ff0000). Coordinates use canvas space — (0, 0) is top-left.
|
|
Always use tools to make changes. After creating nodes, briefly describe what you did.
|
|
Use create_shape + set_layout only for simple single nodes; prefer render for layouts.
|
|
`
|
|
|
|
const providerID = useLocalStorage<AIProviderID>(
|
|
`${STORAGE_PREFIX}ai-provider`,
|
|
DEFAULT_AI_PROVIDER
|
|
)
|
|
const apiKeyStorageKey = computed(() => keyStorageKey(providerID.value))
|
|
const apiKey = useLocalStorage(apiKeyStorageKey, '')
|
|
const modelID = useLocalStorage(`${STORAGE_PREFIX}ai-model`, DEFAULT_AI_MODEL)
|
|
const customBaseURL = useLocalStorage(`${STORAGE_PREFIX}ai-base-url`, '')
|
|
const customModelID = useLocalStorage(`${STORAGE_PREFIX}ai-custom-model`, '')
|
|
const activeTab = ref<'design' | 'ai'>('design')
|
|
|
|
const providerDef = computed(
|
|
() => AI_PROVIDERS.find((p) => p.id === providerID.value) ?? AI_PROVIDERS[0]
|
|
)
|
|
|
|
const isConfigured = computed(() => {
|
|
if (!apiKey.value) return false
|
|
if (providerID.value === 'openai-compatible' && !customBaseURL.value) return false
|
|
return true
|
|
})
|
|
|
|
watch(providerID, (id) => {
|
|
const def = AI_PROVIDERS.find((p) => p.id === id)
|
|
if (def?.defaultModel) {
|
|
modelID.value = def.defaultModel
|
|
}
|
|
resetChat()
|
|
})
|
|
|
|
watch(modelID, () => resetChat())
|
|
watch(customModelID, () => resetChat())
|
|
|
|
function setAPIKey(key: string) {
|
|
apiKey.value = key
|
|
}
|
|
|
|
function createModel(): LanguageModel {
|
|
const key = apiKey.value
|
|
const effectiveModelID =
|
|
providerID.value === 'openai-compatible' ? customModelID.value : modelID.value
|
|
|
|
switch (providerID.value) {
|
|
case 'openrouter': {
|
|
const openrouter = createOpenRouter({
|
|
apiKey: key,
|
|
headers: {
|
|
'X-OpenRouter-Title': 'OpenPencil',
|
|
'HTTP-Referer': 'https://github.com/open-pencil/open-pencil'
|
|
}
|
|
})
|
|
return openrouter(effectiveModelID)
|
|
}
|
|
case 'anthropic': {
|
|
const anthropic = createAnthropic({ apiKey: key })
|
|
return anthropic(effectiveModelID)
|
|
}
|
|
case 'openai': {
|
|
const openai = createOpenAI({ apiKey: key })
|
|
return openai(effectiveModelID)
|
|
}
|
|
case 'google': {
|
|
const google = createGoogleGenerativeAI({ apiKey: key })
|
|
return google(effectiveModelID)
|
|
}
|
|
case 'openai-compatible': {
|
|
const custom = createOpenAI({
|
|
apiKey: key,
|
|
baseURL: customBaseURL.value
|
|
})
|
|
return custom(effectiveModelID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only mock transports don't implement full generics
|
|
let overrideTransport: (() => any) | null = null
|
|
|
|
let chat: Chat<UIMessage> | null = null
|
|
|
|
function createTransport() {
|
|
if (overrideTransport) return overrideTransport()
|
|
|
|
const tools = createAITools(useEditorStore())
|
|
|
|
const agent = new ToolLoopAgent({
|
|
model: createModel(),
|
|
instructions: SYSTEM_PROMPT,
|
|
tools
|
|
})
|
|
|
|
return new DirectChatTransport({ agent })
|
|
}
|
|
|
|
function ensureChat(): Chat<UIMessage> | null {
|
|
if (!isConfigured.value) return null
|
|
if (!chat) {
|
|
chat = new Chat<UIMessage>({
|
|
transport: createTransport()
|
|
})
|
|
}
|
|
return chat
|
|
}
|
|
|
|
function resetChat() {
|
|
chat = null
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.__OPEN_PENCIL_SET_TRANSPORT__ = (factory) => {
|
|
overrideTransport = factory
|
|
}
|
|
}
|
|
|
|
export function useAIChat() {
|
|
return {
|
|
providerID,
|
|
providerDef,
|
|
apiKey,
|
|
setAPIKey,
|
|
modelID,
|
|
customBaseURL,
|
|
customModelID,
|
|
activeTab,
|
|
isConfigured,
|
|
ensureChat,
|
|
resetChat
|
|
}
|
|
}
|