useEditorStore() now returns a Proxy that delegates to the current active store at access time. No more store() wrapper functions, try/catch guards, or passing stores through function parameters.
107 lines
2.9 KiB
TypeScript
107 lines
2.9 KiB
TypeScript
import { Chat } from '@ai-sdk/vue'
|
||
import { DEFAULT_AI_MODEL } from '@open-pencil/core'
|
||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||
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 type { UIMessage } from 'ai'
|
||
|
||
export { AI_MODELS as MODELS } from '@open-pencil/core'
|
||
export type { ModelOption } from '@open-pencil/core'
|
||
|
||
const API_KEY_STORAGE = 'open-pencil:openrouter-api-key'
|
||
const MODEL_STORAGE = 'open-pencil:model'
|
||
|
||
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.
|
||
|
||
Available node types: FRAME (containers/cards), RECTANGLE, ELLIPSE, TEXT, LINE, STAR, POLYGON, SECTION.
|
||
Colors can be hex strings (#ff0000) or RGBA objects with values 0–1.
|
||
Coordinates use canvas space — (0, 0) is the top-left of the page.
|
||
|
||
Always use tools to make changes. After creating nodes, briefly describe what you did.
|
||
When the user asks to create a layout, use create_shape with FRAME, then set_layout for auto-layout.
|
||
`
|
||
|
||
const apiKey = ref(localStorage.getItem(API_KEY_STORAGE) ?? '')
|
||
const modelId = ref(localStorage.getItem(MODEL_STORAGE) ?? DEFAULT_AI_MODEL)
|
||
const activeTab = ref<'design' | 'ai'>('design')
|
||
|
||
watch(apiKey, (key) => {
|
||
if (key) {
|
||
localStorage.setItem(API_KEY_STORAGE, key)
|
||
} else {
|
||
localStorage.removeItem(API_KEY_STORAGE)
|
||
}
|
||
})
|
||
|
||
watch(modelId, (id) => {
|
||
localStorage.setItem(MODEL_STORAGE, id)
|
||
})
|
||
|
||
const isConfigured = computed(() => apiKey.value.length > 0)
|
||
|
||
// 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 openrouter = createOpenRouter({
|
||
apiKey: apiKey.value,
|
||
headers: {
|
||
'X-OpenRouter-Title': 'OpenPencil',
|
||
'HTTP-Referer': 'https://github.com/open-pencil/open-pencil'
|
||
}
|
||
})
|
||
|
||
const tools = createAITools(useEditorStore())
|
||
|
||
const agent = new ToolLoopAgent({
|
||
model: openrouter(modelId.value),
|
||
instructions: SYSTEM_PROMPT,
|
||
tools
|
||
})
|
||
|
||
return new DirectChatTransport({ agent })
|
||
}
|
||
|
||
function ensureChat(): Chat<UIMessage> | null {
|
||
if (!apiKey.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 {
|
||
apiKey,
|
||
modelId,
|
||
activeTab,
|
||
isConfigured,
|
||
ensureChat,
|
||
resetChat
|
||
}
|
||
}
|