Fix chat reactivity (markRaw), add Playwright tests with mock transport
Use markRaw to prevent Vue from deep-proxying the Chat class instance, which broke its internal Vue refs. Add dedent for multiline prompts. Tests use mock transport by default. Set TEST_REAL_LLM=1 and OPENROUTER_API_KEY to run against real OpenRouter.
This commit is contained in:
parent
c18ced5dcf
commit
f6434bc4f9
3
bun.lock
3
bun.lock
|
|
@ -20,6 +20,7 @@
|
|||
"ai": "^6.0.105",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
"dedent": "^1.7.1",
|
||||
"fflate": "^0.8.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"reka-ui": "^2.8.2",
|
||||
|
|
@ -492,6 +493,8 @@
|
|||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"ai": "^6.0.105",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
"dedent": "^1.7.1",
|
||||
"fflate": "^0.8.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"reka-ui": "^2.8.2",
|
||||
|
|
|
|||
|
|
@ -1,30 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { computed, markRaw, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import APIKeySetup from '@/components/chat/APIKeySetup.vue'
|
||||
import ChatInput from '@/components/chat/ChatInput.vue'
|
||||
import ChatMessage from '@/components/chat/ChatMessage.vue'
|
||||
import { useAIChat } from '@/composables/use-chat'
|
||||
|
||||
import type { DesignMessage } from '@/composables/use-chat'
|
||||
import type { Chat } from '@ai-sdk/vue'
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
const { isConfigured, createChat } = useAIChat()
|
||||
const { isConfigured, ensureChat } = useAIChat()
|
||||
|
||||
const chat = ref<Chat<DesignMessage> | null>(null)
|
||||
const chat = ref<Chat<UIMessage> | null>(null)
|
||||
const messagesEnd = ref<HTMLDivElement>()
|
||||
|
||||
const messages = computed(() => chat.value?.messages ?? [])
|
||||
const status = computed(() => chat.value?.status ?? 'ready')
|
||||
|
||||
function ensureChat() {
|
||||
if (!chat.value && isConfigured.value) {
|
||||
chat.value = createChat()
|
||||
}
|
||||
return chat.value
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
messagesEnd.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
|
|
@ -34,9 +27,11 @@ function scrollToBottom() {
|
|||
watch(messages, scrollToBottom, { deep: true })
|
||||
|
||||
function handleSubmit(text: string) {
|
||||
const c = ensureChat()
|
||||
if (!c) return
|
||||
c.sendMessage({ text })
|
||||
if (!chat.value) {
|
||||
const c = ensureChat()
|
||||
if (c) chat.value = markRaw(c)
|
||||
}
|
||||
chat.value?.sendMessage({ text }).catch(() => {})
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,12 @@ const { activeTab } = useAIChat()
|
|||
<DesignPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ai" class="flex min-h-0 flex-1 flex-col">
|
||||
<TabsContent
|
||||
value="ai"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
:force-mount="true"
|
||||
:hidden="activeTab !== 'ai'"
|
||||
>
|
||||
<ChatPanel />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui
|
|||
import { Markdown } from 'vue-stream-markdown'
|
||||
import 'vue-stream-markdown/index.css'
|
||||
|
||||
import type { DesignMessage } from '@/composables/use-chat'
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
const { message } = defineProps<{ message: DesignMessage }>()
|
||||
const { message } = defineProps<{ message: UIMessage }>()
|
||||
|
||||
function getTextContent(msg: DesignMessage): string {
|
||||
function getTextContent(msg: UIMessage): string {
|
||||
return msg.parts
|
||||
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
||||
.map((p) => p.text)
|
||||
|
|
@ -34,7 +34,7 @@ function isToolPart(part: unknown): part is ToolPart {
|
|||
)
|
||||
}
|
||||
|
||||
function getToolParts(msg: DesignMessage): ToolPart[] {
|
||||
function getToolParts(msg: UIMessage): ToolPart[] {
|
||||
return msg.parts.filter(isToolPart)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { Chat } from '@ai-sdk/vue'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import { DirectChatTransport, ToolLoopAgent } from 'ai'
|
||||
import dedent from 'dedent'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { InferAgentUIMessage } from 'ai'
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
const API_KEY_STORAGE = 'open-pencil:openrouter-api-key'
|
||||
const MODEL_STORAGE = 'open-pencil:model'
|
||||
|
|
@ -16,7 +17,6 @@ export interface ModelOption {
|
|||
}
|
||||
|
||||
export const MODEL_OPTIONS: ModelOption[] = [
|
||||
// Design-optimized (vision + tool calling + strong spatial reasoning)
|
||||
{
|
||||
id: 'anthropic/claude-sonnet-4',
|
||||
name: 'Claude Sonnet 4',
|
||||
|
|
@ -31,9 +31,6 @@ export const MODEL_OPTIONS: ModelOption[] = [
|
|||
tag: 'Long context'
|
||||
},
|
||||
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' },
|
||||
|
||||
// Fast & cheap
|
||||
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'Anthropic' },
|
||||
{
|
||||
id: 'google/gemini-2.5-flash-preview-05-20',
|
||||
name: 'Gemini 2.5 Flash',
|
||||
|
|
@ -41,8 +38,6 @@ export const MODEL_OPTIONS: ModelOption[] = [
|
|||
tag: 'Fast'
|
||||
},
|
||||
{ id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini', provider: 'OpenAI', tag: 'Cheap' },
|
||||
|
||||
// Open source
|
||||
{
|
||||
id: 'deepseek/deepseek-chat-v3-0324:free',
|
||||
name: 'DeepSeek V3',
|
||||
|
|
@ -57,7 +52,6 @@ export const MODEL_OPTIONS: ModelOption[] = [
|
|||
}
|
||||
]
|
||||
|
||||
// Deduplicate by id, keeping first occurrence
|
||||
const seen = new Set<string>()
|
||||
const deduped: ModelOption[] = []
|
||||
for (const m of MODEL_OPTIONS) {
|
||||
|
|
@ -67,9 +61,14 @@ for (const m of MODEL_OPTIONS) {
|
|||
}
|
||||
}
|
||||
export const MODELS = deduped
|
||||
|
||||
export const DEFAULT_MODEL = MODELS[0].id
|
||||
|
||||
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.
|
||||
`
|
||||
|
||||
const apiKey = ref(localStorage.getItem(API_KEY_STORAGE) ?? '')
|
||||
const modelId = ref(localStorage.getItem(MODEL_STORAGE) ?? DEFAULT_MODEL)
|
||||
const activeTab = ref<'design' | 'ai'>('design')
|
||||
|
|
@ -88,7 +87,14 @@ watch(modelId, (id) => {
|
|||
|
||||
const isConfigured = computed(() => apiKey.value.length > 0)
|
||||
|
||||
function createAgent() {
|
||||
// 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: {
|
||||
|
|
@ -97,25 +103,32 @@ function createAgent() {
|
|||
}
|
||||
})
|
||||
|
||||
return new ToolLoopAgent({
|
||||
const agent = new ToolLoopAgent({
|
||||
model: openrouter(modelId.value),
|
||||
instructions:
|
||||
'You are a design assistant inside OpenPencil, a Figma-like design editor. ' +
|
||||
'Help users create and modify designs. Be concise and direct.'
|
||||
instructions: SYSTEM_PROMPT
|
||||
})
|
||||
|
||||
return new DirectChatTransport({ agent })
|
||||
}
|
||||
|
||||
type DesignAgent = ReturnType<typeof createAgent>
|
||||
export type DesignMessage = InferAgentUIMessage<DesignAgent>
|
||||
|
||||
function createChat() {
|
||||
function ensureChat(): Chat<UIMessage> | null {
|
||||
if (!apiKey.value) return null
|
||||
if (!chat) {
|
||||
chat = new Chat<UIMessage>({
|
||||
transport: createTransport()
|
||||
})
|
||||
}
|
||||
return chat
|
||||
}
|
||||
|
||||
const agent = createAgent()
|
||||
function resetChat() {
|
||||
chat = null
|
||||
}
|
||||
|
||||
return new Chat<DesignMessage>({
|
||||
transport: new DirectChatTransport({ agent })
|
||||
})
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__OPEN_PENCIL_SET_TRANSPORT__ = (factory) => {
|
||||
overrideTransport = factory
|
||||
}
|
||||
}
|
||||
|
||||
export function useAIChat() {
|
||||
|
|
@ -124,6 +137,7 @@ export function useAIChat() {
|
|||
modelId,
|
||||
activeTab,
|
||||
isConfigured,
|
||||
createChat
|
||||
ensureChat,
|
||||
resetChat
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
src/global.d.ts
vendored
2
src/global.d.ts
vendored
|
|
@ -20,4 +20,6 @@ interface Window {
|
|||
blob(): Promise<Blob>
|
||||
}[]
|
||||
>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
__OPEN_PENCIL_SET_TRANSPORT__?(factory: () => any): void
|
||||
}
|
||||
|
|
|
|||
153
tests/e2e/chat-panel.spec.ts
Normal file
153
tests/e2e/chat-panel.spec.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { CanvasHelper } from '../helpers/canvas'
|
||||
|
||||
const USE_REAL_LLM = process.env.TEST_REAL_LLM === '1'
|
||||
const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY ?? ''
|
||||
|
||||
let page: Page
|
||||
let canvas: CanvasHelper
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await page.goto('/')
|
||||
canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
|
||||
if (!USE_REAL_LLM) {
|
||||
await injectMockTransport(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close()
|
||||
})
|
||||
|
||||
async function injectMockTransport(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const setTransport = window.__OPEN_PENCIL_SET_TRANSPORT__
|
||||
if (!setTransport) throw new Error('Transport override not available')
|
||||
|
||||
setTransport(() => ({
|
||||
async sendMessages({
|
||||
messages,
|
||||
}: {
|
||||
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>
|
||||
}) {
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
||||
const text = lastUser?.parts?.find((p) => p.type === 'text')?.text ?? ''
|
||||
|
||||
const words = `I'll help you with: "${text}". Here's a mock response.`.split(' ')
|
||||
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({ type: 'start', messageId: 'mock-msg-1' })
|
||||
controller.enqueue({ type: 'text-start', id: 'text-1' })
|
||||
for (const word of words) {
|
||||
controller.enqueue({ type: 'text-delta', id: 'text-1', delta: word + ' ' })
|
||||
}
|
||||
controller.enqueue({ type: 'text-end', id: 'text-1' })
|
||||
controller.enqueue({ type: 'finish', finishReason: 'stop' })
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
},
|
||||
async reconnectToStream() {
|
||||
return null
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function chatTab() {
|
||||
return page.getByRole('tab', { name: 'AI' })
|
||||
}
|
||||
|
||||
function designTab() {
|
||||
return page.getByRole('tab', { name: 'Design' })
|
||||
}
|
||||
|
||||
function chatInput() {
|
||||
return page.locator('input[placeholder="Describe a change…"]')
|
||||
}
|
||||
|
||||
function apiKeyInput() {
|
||||
return page.locator('input[placeholder="sk-or-…"]')
|
||||
}
|
||||
|
||||
test('⌘J switches to AI tab', async () => {
|
||||
await designTab().waitFor()
|
||||
await page.keyboard.press('Meta+j')
|
||||
await expect(chatTab()).toHaveAttribute('data-state', 'active')
|
||||
})
|
||||
|
||||
test('⌘J switches back to Design tab', async () => {
|
||||
await page.keyboard.press('Meta+j')
|
||||
await expect(designTab()).toHaveAttribute('data-state', 'active')
|
||||
})
|
||||
|
||||
test('clicking AI tab shows API key setup when no key set', async () => {
|
||||
await chatTab().click()
|
||||
await expect(apiKeyInput()).toBeVisible()
|
||||
await expect(page.getByText('Enter your OpenRouter API key')).toBeVisible()
|
||||
})
|
||||
|
||||
test('saving API key shows chat interface', async () => {
|
||||
const key = USE_REAL_LLM ? OPENROUTER_KEY : 'sk-or-test-key-12345'
|
||||
await apiKeyInput().fill(key)
|
||||
await page.locator('button:has-text("Save")').click()
|
||||
|
||||
await expect(chatInput()).toBeVisible()
|
||||
await expect(page.getByText('Describe what you want to create or change.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('empty input has disabled send button', async () => {
|
||||
const sendButton = page.locator('button[type="submit"]')
|
||||
await expect(sendButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('typing enables send button', async () => {
|
||||
await chatInput().fill('Make a red rectangle')
|
||||
const sendButton = page.locator('button[type="submit"]')
|
||||
await expect(sendButton).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Enter submits message and clears input', async () => {
|
||||
await chatInput().fill('Make a red rectangle')
|
||||
await chatInput().press('Enter')
|
||||
|
||||
await expect(page.getByText('Make a red rectangle', { exact: true })).toBeVisible({ timeout: 5000 })
|
||||
await expect(chatInput()).toHaveValue('')
|
||||
})
|
||||
|
||||
test('assistant responds', async () => {
|
||||
if (USE_REAL_LLM) {
|
||||
await expect(
|
||||
page.locator('.chat-markdown, [class*="rounded-tl-md"]').first(),
|
||||
).toBeVisible({ timeout: 30000 })
|
||||
} else {
|
||||
await expect(page.getByText('mock response', { exact: false })).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
})
|
||||
|
||||
test('model selector is visible and clickable', async () => {
|
||||
const trigger = page.getByRole('combobox')
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
await expect(page.getByText('Claude Sonnet 4')).toBeVisible()
|
||||
await expect(page.getByText('Recommended')).toBeVisible()
|
||||
await expect(page.getByText('Free').first()).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test('switching tabs preserves chat', async () => {
|
||||
await designTab().click()
|
||||
await expect(designTab()).toHaveAttribute('data-state', 'active')
|
||||
|
||||
await chatTab().click()
|
||||
await expect(page.getByText('Make a red rectangle', { exact: true })).toBeVisible()
|
||||
})
|
||||
Loading…
Reference in a new issue