AI chat panel with ⌘J toggle, DirectChatTransport, OpenRouter
- ChatPanel.vue: message list, tool timeline, typing indicator, API key setup - use-chat.ts: Chat + DirectChatTransport + ToolLoopAgent (no backend) - App.vue: toggle between PropertiesPanel and ChatPanel in same splitter slot - ⌘J keyboard shortcut to open/close chat
This commit is contained in:
parent
cc5dda29d9
commit
5d23c3e834
32
bun.lock
32
bun.lock
|
|
@ -5,8 +5,11 @@
|
|||
"": {
|
||||
"name": "open-pencil-app",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.37",
|
||||
"@ai-sdk/vue": "^3.0.105",
|
||||
"@open-pencil/cli": "workspace:*",
|
||||
"@open-pencil/core": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "^2.2.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
|
|
@ -14,6 +17,7 @@
|
|||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"ai": "^6.0.105",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
"fflate": "^0.8.2",
|
||||
|
|
@ -72,6 +76,16 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bcYjT3/58i/C0DN3AnrjiGsAb0kYivZLWWUtgTjsBurHSht/LTEy+w3dw5XQe3FmZwX7Z/mUQCiA3wB/5Kf7ow=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="],
|
||||
|
||||
"@ai-sdk/vue": ["@ai-sdk/vue@3.0.105", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.16", "ai": "6.0.105", "swrv": "^1.0.4" }, "peerDependencies": { "vue": "^3.3.4" } }, "sha512-qdf4G6p8lubBrDKw9u6gG7KJft+J7A/xqk0WRsWw+KAJkG+TgziaK4a+/5amuhOQnvnQL/NFlmQHfYnHzM2KMA=="],
|
||||
|
||||
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
|
@ -166,6 +180,10 @@
|
|||
|
||||
"@open-pencil/core": ["@open-pencil/core@workspace:packages/core"],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.2.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.35.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.35.0", "", { "os": "android", "cpu": "arm64" }, "sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ=="],
|
||||
|
|
@ -296,6 +314,8 @@
|
|||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
|
||||
|
|
@ -394,6 +414,8 @@
|
|||
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260228.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9Wq1d0ZbrJcF6NoODw4+SRNdcZtKMmm2w32Wlo+r6ZIziBF5HFWvJcwGXNE0pbFpX6H/Gs8dhLaDKZU2dLoWOA=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
|
||||
|
|
@ -426,6 +448,8 @@
|
|||
|
||||
"agentfmt": ["agentfmt@0.1.3", "", { "dependencies": { "picocolors": "^1.1.1" } }, "sha512-BVSZr8ovigxjYewuBx42Ep4RJj00HURaQYUQ+id41sgb7r0PMUSEWsnD/+h96xj6t6v2WVyPuGPH0K6RtjftKQ=="],
|
||||
|
||||
"ai": ["ai@6.0.105", "", { "dependencies": { "@ai-sdk/gateway": "3.0.59", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
|
@ -456,6 +480,8 @@
|
|||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
|
@ -470,6 +496,8 @@
|
|||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
|
|
@ -536,6 +564,8 @@
|
|||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"swrv": ["swrv@1.1.0", "", { "peerDependencies": { "vue": ">=3.2.26 < 4" } }, "sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
|
@ -572,6 +602,8 @@
|
|||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
|
|
|||
|
|
@ -22,8 +22,11 @@
|
|||
"open-pencil": "bun packages/cli/src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.37",
|
||||
"@ai-sdk/vue": "^3.0.105",
|
||||
"@open-pencil/cli": "workspace:*",
|
||||
"@open-pencil/core": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "^2.2.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
|
|
@ -31,6 +34,7 @@
|
|||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"ai": "^6.0.105",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
"fflate": "^0.8.2",
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
|
||||
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
||||
|
||||
import { useAIChat } from './composables/use-chat'
|
||||
import { useKeyboard } from './composables/use-keyboard'
|
||||
import { useMenu } from './composables/use-menu'
|
||||
import { createDemoShapes } from './demo'
|
||||
import { provideEditorStore } from './stores/editor'
|
||||
|
||||
import ChatPanel from './components/ChatPanel.vue'
|
||||
import EditorCanvas from './components/EditorCanvas.vue'
|
||||
import LayersPanel from './components/LayersPanel.vue'
|
||||
import PropertiesPanel from './components/PropertiesPanel.vue'
|
||||
|
|
@ -17,6 +19,8 @@ useKeyboard(store)
|
|||
useMenu(store)
|
||||
;(window as Window & { __OPEN_PENCIL_STORE__?: typeof store }).__OPEN_PENCIL_STORE__ = store
|
||||
|
||||
const { chatPanelOpen } = useAIChat()
|
||||
|
||||
useEventListener(
|
||||
document,
|
||||
'wheel',
|
||||
|
|
@ -57,7 +61,8 @@ if (!('test' in params)) {
|
|||
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2" />
|
||||
</SplitterResizeHandle>
|
||||
<SplitterPanel :default-size="15" :min-size="10" :max-size="30" class="flex">
|
||||
<PropertiesPanel />
|
||||
<ChatPanel v-if="chatPanelOpen" />
|
||||
<PropertiesPanel v-else />
|
||||
</SplitterPanel>
|
||||
</SplitterGroup>
|
||||
<div v-else class="flex flex-1 overflow-hidden">
|
||||
|
|
|
|||
280
src/components/ChatPanel.vue
Normal file
280
src/components/ChatPanel.vue
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import { useAIChat } from '@/composables/use-chat'
|
||||
|
||||
import type { DesignMessage } from '@/composables/use-chat'
|
||||
import type { Chat } from '@ai-sdk/vue'
|
||||
|
||||
const { apiKey, isConfigured, createChat } = useAIChat()
|
||||
|
||||
const chat = ref<Chat<DesignMessage> | null>(null)
|
||||
const input = ref('')
|
||||
const messagesEnd = ref<HTMLDivElement>()
|
||||
const apiKeyInput = ref('')
|
||||
|
||||
const messages = computed(() => chat.value?.messages ?? [])
|
||||
const status = computed(() => chat.value?.status ?? 'ready')
|
||||
const isStreaming = computed(() => status.value === 'streaming' || status.value === 'submitted')
|
||||
|
||||
function ensureChat() {
|
||||
if (!chat.value && isConfigured.value) {
|
||||
chat.value = createChat()
|
||||
}
|
||||
return chat.value
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
messagesEnd.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
})
|
||||
}
|
||||
|
||||
watch(messages, scrollToBottom, { deep: true })
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const text = input.value.trim()
|
||||
if (!text) return
|
||||
|
||||
const c = ensureChat()
|
||||
if (!c) return
|
||||
|
||||
c.sendMessage({ text })
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
chat.value?.stop()
|
||||
}
|
||||
|
||||
function saveApiKey() {
|
||||
apiKey.value = apiKeyInput.value.trim()
|
||||
apiKeyInput.value = ''
|
||||
}
|
||||
|
||||
function getTextContent(msg: DesignMessage): string {
|
||||
return msg.parts
|
||||
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
||||
.map((p) => p.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
state: string
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
errorText?: string
|
||||
}
|
||||
|
||||
function isToolPart(part: unknown): part is ToolPart {
|
||||
return (
|
||||
typeof part === 'object' &&
|
||||
part !== null &&
|
||||
'type' in part &&
|
||||
typeof (part as ToolPart).type === 'string' &&
|
||||
(part as ToolPart).type.startsWith('tool-')
|
||||
)
|
||||
}
|
||||
|
||||
function getToolParts(msg: DesignMessage): ToolPart[] {
|
||||
return msg.parts.filter(isToolPart)
|
||||
}
|
||||
|
||||
function toolDisplayName(name: string): string {
|
||||
return name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function toolState(part: ToolPart): 'pending' | 'done' | 'error' {
|
||||
if (part.state === 'error') return 'error'
|
||||
if (part.state === 'output-available') return 'done'
|
||||
return 'pending'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="flex min-w-0 flex-1 flex-col overflow-hidden border-l border-border bg-panel"
|
||||
style="contain: paint layout style"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-10 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<icon-lucide-sparkles class="size-4 text-accent" />
|
||||
<span class="text-xs font-semibold">AI Chat</span>
|
||||
<span v-if="isStreaming" class="ml-auto text-[10px] text-muted"> streaming… </span>
|
||||
</div>
|
||||
|
||||
<!-- API key setup -->
|
||||
<div v-if="!isConfigured" class="flex flex-1 flex-col items-center justify-center gap-4 px-4">
|
||||
<icon-lucide-key-round class="size-8 text-muted" />
|
||||
<p class="text-center text-xs text-muted">Enter your OpenRouter API key to start chatting.</p>
|
||||
<form class="flex w-full gap-1.5" @submit.prevent="saveApiKey">
|
||||
<input
|
||||
v-model="apiKeyInput"
|
||||
type="password"
|
||||
placeholder="sk-or-…"
|
||||
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="shrink-0 rounded bg-accent px-2.5 py-1 text-xs font-medium text-white hover:bg-accent/90"
|
||||
:disabled="!apiKeyInput.trim()"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
class="text-[10px] text-muted underline hover:text-surface"
|
||||
>
|
||||
Get an API key →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Chat messages -->
|
||||
<template v-else>
|
||||
<div class="flex-1 overflow-y-auto px-3 py-3">
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="messages.length === 0"
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-muted"
|
||||
>
|
||||
<icon-lucide-message-circle class="size-8 opacity-50" />
|
||||
<p class="text-xs">Describe what you want to create or change.</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="flex gap-2"
|
||||
:class="msg.role === 'user' ? 'flex-row-reverse' : ''"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold"
|
||||
:class="msg.role === 'user' ? 'bg-accent/20 text-accent' : 'bg-muted/20 text-muted'"
|
||||
>
|
||||
{{ msg.role === 'user' ? 'U' : 'AI' }}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 max-w-[85%] space-y-1.5">
|
||||
<!-- Tool timeline -->
|
||||
<div
|
||||
v-if="msg.role === 'assistant' && getToolParts(msg).length > 0"
|
||||
class="rounded-lg border border-border bg-canvas p-2"
|
||||
>
|
||||
<div
|
||||
v-for="tool in getToolParts(msg)"
|
||||
:key="tool.toolCallId"
|
||||
class="flex items-center gap-2 py-0.5"
|
||||
>
|
||||
<div
|
||||
class="flex size-4 items-center justify-center rounded-full"
|
||||
:class="{
|
||||
'bg-accent/20 text-accent': toolState(tool) === 'pending',
|
||||
'bg-green-500/20 text-green-400': toolState(tool) === 'done',
|
||||
'bg-red-500/20 text-red-400': toolState(tool) === 'error'
|
||||
}"
|
||||
>
|
||||
<icon-lucide-loader-circle
|
||||
v-if="toolState(tool) === 'pending'"
|
||||
class="size-3 animate-spin"
|
||||
/>
|
||||
<icon-lucide-check v-else-if="toolState(tool) === 'done'" class="size-3" />
|
||||
<icon-lucide-triangle-alert v-else class="size-3" />
|
||||
</div>
|
||||
<span class="text-[11px] text-surface">
|
||||
{{ toolDisplayName(tool.toolName) }}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted">
|
||||
{{
|
||||
toolState(tool) === 'pending'
|
||||
? 'Running…'
|
||||
: toolState(tool) === 'done'
|
||||
? 'Done'
|
||||
: 'Error'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text bubble -->
|
||||
<div
|
||||
v-if="getTextContent(msg)"
|
||||
class="whitespace-pre-wrap rounded-xl px-3 py-2 text-xs leading-relaxed"
|
||||
:class="
|
||||
msg.role === 'user'
|
||||
? 'rounded-br-md bg-accent text-white'
|
||||
: 'rounded-tl-md bg-hover text-surface'
|
||||
"
|
||||
>
|
||||
{{ getTextContent(msg) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typing indicator -->
|
||||
<div v-if="status === 'submitted'" class="flex gap-2">
|
||||
<div
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
|
||||
>
|
||||
AI
|
||||
</div>
|
||||
<div class="flex items-center gap-1 py-2">
|
||||
<span
|
||||
class="size-1.5 animate-bounce rounded-full bg-muted"
|
||||
style="animation-delay: 0ms"
|
||||
/>
|
||||
<span
|
||||
class="size-1.5 animate-bounce rounded-full bg-muted"
|
||||
style="animation-delay: 150ms"
|
||||
/>
|
||||
<span
|
||||
class="size-1.5 animate-bounce rounded-full bg-muted"
|
||||
style="animation-delay: 300ms"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="messagesEnd" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="shrink-0 border-t border-border px-3 py-2">
|
||||
<form class="flex gap-1.5" @submit="handleSubmit">
|
||||
<input
|
||||
v-model="input"
|
||||
type="text"
|
||||
placeholder="Describe a change…"
|
||||
class="min-w-0 flex-1 rounded border border-border bg-input px-2.5 py-1.5 text-xs text-surface outline-none placeholder:text-muted focus:border-accent"
|
||||
:disabled="status === 'submitted'"
|
||||
/>
|
||||
<button
|
||||
v-if="isStreaming"
|
||||
type="button"
|
||||
class="shrink-0 rounded border border-border px-2 py-1.5 text-xs text-muted hover:bg-hover"
|
||||
@click="handleStop"
|
||||
>
|
||||
<icon-lucide-square class="size-3" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="submit"
|
||||
class="shrink-0 rounded bg-accent px-2.5 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
|
||||
:disabled="!input.trim()"
|
||||
>
|
||||
<icon-lucide-send class="size-3" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
54
src/composables/use-chat.ts
Normal file
54
src/composables/use-chat.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Chat } from '@ai-sdk/vue'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import { DirectChatTransport, ToolLoopAgent } from 'ai'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { InferAgentUIMessage } from 'ai'
|
||||
|
||||
const API_KEY_STORAGE = 'open-pencil:openrouter-api-key'
|
||||
|
||||
const apiKey = ref(localStorage.getItem(API_KEY_STORAGE) ?? '')
|
||||
const chatPanelOpen = ref(false)
|
||||
|
||||
watch(apiKey, (key) => {
|
||||
if (key) {
|
||||
localStorage.setItem(API_KEY_STORAGE, key)
|
||||
} else {
|
||||
localStorage.removeItem(API_KEY_STORAGE)
|
||||
}
|
||||
})
|
||||
|
||||
const isConfigured = computed(() => apiKey.value.length > 0)
|
||||
|
||||
function createAgent() {
|
||||
const openrouter = createOpenRouter({ apiKey: apiKey.value })
|
||||
|
||||
return new ToolLoopAgent({
|
||||
model: openrouter('anthropic/claude-sonnet-4'),
|
||||
instructions:
|
||||
'You are a design assistant inside OpenPencil, a Figma-like design editor. ' +
|
||||
'Help users create and modify designs. Be concise and direct.'
|
||||
})
|
||||
}
|
||||
|
||||
type DesignAgent = ReturnType<typeof createAgent>
|
||||
export type DesignMessage = InferAgentUIMessage<DesignAgent>
|
||||
|
||||
function createChat() {
|
||||
if (!apiKey.value) return null
|
||||
|
||||
const agent = createAgent()
|
||||
|
||||
return new Chat<DesignMessage>({
|
||||
transport: new DirectChatTransport({ agent })
|
||||
})
|
||||
}
|
||||
|
||||
export function useAIChat() {
|
||||
return {
|
||||
apiKey,
|
||||
chatPanelOpen,
|
||||
isConfigured,
|
||||
createChat
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { useAIChat } from '@/composables/use-chat'
|
||||
import { TOOL_SHORTCUTS } from '@/stores/editor'
|
||||
|
||||
import { openFileDialog } from './use-menu'
|
||||
|
|
@ -11,6 +12,7 @@ function isEditing(e: Event) {
|
|||
}
|
||||
|
||||
export function useKeyboard(store: EditorStore) {
|
||||
const { chatPanelOpen } = useAIChat()
|
||||
useEventListener(window, 'copy', (e: ClipboardEvent) => {
|
||||
if (isEditing(e)) return
|
||||
e.preventDefault()
|
||||
|
|
@ -79,6 +81,11 @@ export function useKeyboard(store: EditorStore) {
|
|||
}
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (e.code === 'KeyJ') {
|
||||
e.preventDefault()
|
||||
chatPanelOpen.value = !chatPanelOpen.value
|
||||
return
|
||||
}
|
||||
if (e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
store.undoAction()
|
||||
|
|
|
|||
Loading…
Reference in a new issue