fix(ai): allow custom OpenRouter models

This commit is contained in:
Danila Poyarkov 2026-05-19 13:59:31 +03:00
parent 8fa34fb0b8
commit 8cb6d008fc
40 changed files with 952 additions and 298 deletions

View file

@ -2,6 +2,10 @@
## Unreleased
### Fixes
- Allow OpenRouter users to enter any model ID from provider settings with cached autocomplete suggestions for tool-capable models, while keeping the curated dropdown as the default when no custom model is set.
## 0.12.1 — 2026-05-19
### Fixes
@ -357,14 +361,12 @@
- Fix text typography lost on Figma clipboard import — preserve fontFamily, fontWeight, fontSize, lineHeight
- Fix `copyFill` missing `gradientTransform` and `imageTransform` — gradient fills now round-trip correctly
### Performance
- Event-driven rendering and component sync — `SceneGraph` emits typed events on mutations; `requestRender()` calls reduced from 94 to 22, component instance sync uses microtask batching with deduplication
- Replace `structuredClone` with typed copy helpers for fills, strokes, effects, and style runs (~24× faster in hot paths)
- Filter .fig unzip to only decompress canvas and image entries, skipping metadata cruft
### Improvements
- Padding on a frame auto-enables vertical auto-layout
@ -493,7 +495,6 @@
- Apple code signing and notarization for macOS builds
- Git LFS storage moved from GitHub to Cloudflare R2
### Fixes
- Fix Figma clipboard paste: extract shared kiwi→SceneNode conversion, fixing broken auto-layout, missing gradient/image fills, effects, style runs, and text properties

View file

@ -871,6 +871,7 @@ const noDirectStorageAccess = {
const file = normalizedFilename(context)
const allowedFiles = [
'/src/app/ai/chat/storage.ts',
'/src/app/cache/index.ts',
'/src/app/shell/layout-storage.ts',
'/packages/vue/src/i18n/locale.ts'
]

View file

@ -208,6 +208,7 @@ export const AI_PROVIDERS: AIProviderDef[] = [
keyPlaceholder: 'sk-or-…',
keyURL: 'https://openrouter.ai/keys',
defaultModel: 'anthropic/claude-sonnet-4.6',
supportsCustomModel: true,
models: [
{ id: 'anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6', tag: 'Best for design' },
{ id: 'anthropic/claude-opus-4.6', name: 'Claude Opus 4.6', tag: 'Smartest' },

View file

@ -16,10 +16,19 @@ export type ModelConfig = {
customAPIType: 'completions' | 'responses'
}
export function resolveLanguageModelID(
config: Pick<ModelConfig, 'providerID' | 'modelID' | 'customModelID'>
) {
const customModelID = config.customModelID.trim()
if (config.providerID === 'openrouter') return customModelID || config.modelID
if (config.providerID === 'openai-compatible' || config.providerID === 'anthropic-compatible') {
return customModelID
}
return config.modelID
}
export function createLanguageModel(config: ModelConfig): LanguageModel {
const needsCustomModel =
config.providerID === 'openai-compatible' || config.providerID === 'anthropic-compatible'
const effectiveModelID = needsCustomModel ? config.customModelID : config.modelID
const effectiveModelID = resolveLanguageModelID(config)
switch (config.providerID) {
case 'openrouter': {

View file

@ -0,0 +1,74 @@
import { AI_PROVIDERS } from '@open-pencil/core/constants'
import type { AIProviderID, ModelOption } from '@open-pencil/core/constants'
import { readCacheJson, writeCacheJson } from '@/app/cache'
type OpenRouterModel = {
id?: unknown
name?: unknown
supported_parameters?: unknown
}
type OpenRouterModelsResponse = {
data?: OpenRouterModel[]
}
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'
const OPENROUTER_MODELS_CACHE_KEY = 'openrouter/models'
const OPENROUTER_MODELS_CACHE_TTL_MS = 24 * 60 * 60 * 1000
function curatedProviderModels(providerID: AIProviderID) {
return AI_PROVIDERS.find((provider) => provider.id === providerID)?.models ?? []
}
const curatedOpenRouterModels = curatedProviderModels('openrouter')
let modelsPromise: Promise<ModelOption[]> | null = null
function isToolCapableOpenRouterModel(model: OpenRouterModel) {
return Array.isArray(model.supported_parameters) && model.supported_parameters.includes('tools')
}
export function normalizeOpenRouterModel(model: OpenRouterModel): ModelOption | null {
if (!isToolCapableOpenRouterModel(model)) return null
if (typeof model.id !== 'string' || !model.id) return null
return {
id: model.id,
name: typeof model.name === 'string' && model.name ? model.name : model.id
}
}
async function fetchOpenRouterModels(fetcher: typeof fetch): Promise<ModelOption[]> {
const response = await fetcher(OPENROUTER_MODELS_URL)
if (!response.ok) throw new Error(`OpenRouter models request failed: ${response.status}`)
const json = (await response.json()) as OpenRouterModelsResponse
return json.data?.map(normalizeOpenRouterModel).filter((model) => model !== null) ?? []
}
async function listOpenRouterModels(fetcher: typeof fetch = fetch): Promise<ModelOption[]> {
modelsPromise ??= (async () => {
const cached = await readCacheJson<ModelOption[]>(
OPENROUTER_MODELS_CACHE_KEY,
OPENROUTER_MODELS_CACHE_TTL_MS
)
if (cached?.length) return cached
try {
const models = await fetchOpenRouterModels(fetcher)
if (!models.length) return curatedOpenRouterModels
await writeCacheJson(OPENROUTER_MODELS_CACHE_KEY, models)
return models
} catch {
return curatedOpenRouterModels
}
})()
return modelsPromise
}
export async function listProviderModels(
providerID: AIProviderID,
fetcher: typeof fetch = fetch
): Promise<ModelOption[]> {
if (providerID === 'openrouter') return listOpenRouterModels(fetcher)
return curatedProviderModels(providerID)
}

View file

@ -6,7 +6,7 @@ import type { ComputedRef, Ref } from 'vue'
import { ACP_AGENTS } from '@open-pencil/core/constants'
import type { ACPAgentID, AIProviderID } from '@open-pencil/core/constants'
import { createLanguageModel } from '@/app/ai/chat/model'
import { createLanguageModel, resolveLanguageModelID } from '@/app/ai/chat/model'
import SYSTEM_PROMPT from '@/app/ai/chat/system-prompt.md?raw'
import { MAX_AGENT_STEPS, createAITools, recordStepUsage, resetRunSteps } from '@/app/ai/tools'
import type { getActiveEditorStore } from '@/app/editor/active-store'
@ -70,7 +70,8 @@ export function createToolLoopTransport({
maxOutputTokens
}: ToolLoopTransportOptions) {
const tools = createAITools(store)
const cacheProviderOptions = supportsAnthropicCaching(providerID, modelID)
const effectiveModelID = resolveLanguageModelID({ providerID, modelID, customModelID })
const cacheProviderOptions = supportsAnthropicCaching(providerID, effectiveModelID)
? ANTHROPIC_CACHE_CONTROL
: undefined

129
src/app/cache/index.ts vendored Normal file
View file

@ -0,0 +1,129 @@
import { IS_BROWSER, IS_TAURI } from '@open-pencil/core/constants'
const APP_CACHE_DIR = 'cache/v1'
const STORAGE_PREFIX = 'open-pencil:cache:v1:'
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
function isTauriRuntime() {
return IS_TAURI
}
function isStorageAvailable() {
return IS_BROWSER || 'window' in globalThis
}
function cachePath(key: string) {
return `${APP_CACHE_DIR}/${key.split('/').map(encodeURIComponent).join('/')}`
}
function storageKey(key: string) {
return `${STORAGE_PREFIX}${key}`
}
export async function readCacheText(key: string): Promise<string | null> {
if (isTauriRuntime()) {
try {
const { BaseDirectory, readFile } = await import('@tauri-apps/plugin-fs')
return textDecoder.decode(
await readFile(cachePath(key), { baseDir: BaseDirectory.AppLocalData })
)
} catch {
return null
}
}
if (!isStorageAvailable()) return null
return window.localStorage.getItem(storageKey(key))
}
export async function writeCacheText(key: string, value: string): Promise<void> {
if (isTauriRuntime()) {
const { BaseDirectory, mkdir, writeFile } = await import('@tauri-apps/plugin-fs')
await mkdir(APP_CACHE_DIR, { baseDir: BaseDirectory.AppLocalData, recursive: true })
await writeFile(cachePath(key), textEncoder.encode(value), {
baseDir: BaseDirectory.AppLocalData
})
return
}
if (!isStorageAvailable()) return
window.localStorage.setItem(storageKey(key), value)
}
export async function removeCacheEntry(key: string): Promise<void> {
if (isTauriRuntime()) {
try {
const { BaseDirectory, remove } = await import('@tauri-apps/plugin-fs')
await remove(cachePath(key), { baseDir: BaseDirectory.AppLocalData })
} catch (error) {
console.warn(`Cache delete skipped for "${key}":`, error)
}
return
}
if (!isStorageAvailable()) return
window.localStorage.removeItem(storageKey(key))
}
export async function readCacheBytes(key: string): Promise<ArrayBuffer | null> {
if (!isTauriRuntime()) return null
try {
const { BaseDirectory, readFile } = await import('@tauri-apps/plugin-fs')
const data = await readFile(cachePath(key), { baseDir: BaseDirectory.AppLocalData })
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
} catch {
return null
}
}
export async function writeCacheBytes(key: string, value: ArrayBuffer): Promise<void> {
if (!isTauriRuntime()) return
const { BaseDirectory, mkdir, writeFile } = await import('@tauri-apps/plugin-fs')
await mkdir(APP_CACHE_DIR, { baseDir: BaseDirectory.AppLocalData, recursive: true })
await writeFile(cachePath(key), new Uint8Array(value), { baseDir: BaseDirectory.AppLocalData })
}
export async function removeCachePrefix(prefix: string): Promise<void> {
if (isTauriRuntime()) {
try {
const { BaseDirectory, remove } = await import('@tauri-apps/plugin-fs')
await remove(cachePath(prefix), { baseDir: BaseDirectory.AppLocalData, recursive: true })
} catch (error) {
console.warn(`Cache prefix delete skipped for "${prefix}":`, error)
}
return
}
if (!isStorageAvailable()) return
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const key = window.localStorage.key(i)
if (key?.startsWith(storageKey(prefix))) window.localStorage.removeItem(key)
}
}
type JsonCacheEnvelope<T> = {
updatedAt: number
value: T
}
export async function readCacheJson<T>(key: string, maxAgeMs?: number): Promise<T | null> {
const raw = await readCacheText(key)
if (!raw) return null
try {
const envelope = JSON.parse(raw) as Partial<JsonCacheEnvelope<T>>
if (typeof envelope.updatedAt !== 'number' || !('value' in envelope)) return null
if (maxAgeMs !== undefined && Date.now() - envelope.updatedAt > maxAgeMs) return null
return envelope.value as T
} catch {
return null
}
}
export async function writeCacheJson(key: string, value: unknown): Promise<void> {
await writeCacheText(key, JSON.stringify({ updatedAt: Date.now(), value }))
}

View file

@ -1,5 +1,13 @@
import type { DownloadedFontCache } from '@open-pencil/core/text'
import {
readCacheBytes,
readCacheJson,
removeCachePrefix,
writeCacheBytes,
writeCacheJson
} from '@/app/cache'
type FontCacheEntry = {
family: string
style: string
@ -21,11 +29,10 @@ export interface DownloadedFontCacheSummary {
}
const CACHE_DIR = 'font-cache/v1'
const MANIFEST_PATH = `${CACHE_DIR}/manifest.json`
const MANIFEST_PATH = `${CACHE_DIR}/manifest`
const FILE_DIR = `${CACHE_DIR}/files`
const EMPTY_MANIFEST: FontCacheManifest = { version: 1, entries: {} }
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
async function cacheKey(family: string, style: string) {
return hashText(`${family}\0${style}`)
@ -45,32 +52,14 @@ function hexDigest(data: ArrayBuffer) {
return [...new Uint8Array(data)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}
function isTauriRuntime() {
return 'window' in globalThis && '__TAURI_INTERNALS__' in globalThis.window
}
function isUnavailableTauriRuntimeError(error: unknown) {
return error instanceof ReferenceError && error.message.includes('window is not defined')
}
async function readManifest(): Promise<FontCacheManifest> {
const { BaseDirectory, readFile } = await import('@tauri-apps/plugin-fs')
try {
const data = await readFile(MANIFEST_PATH, { baseDir: BaseDirectory.AppLocalData })
const parsed = JSON.parse(textDecoder.decode(data)) as Partial<FontCacheManifest>
if (parsed.version !== 1 || !parsed.entries) return EMPTY_MANIFEST
return { version: 1, entries: parsed.entries }
} catch {
return { version: 1, entries: {} }
}
const manifest = await readCacheJson<Partial<FontCacheManifest>>(MANIFEST_PATH)
if (manifest?.version !== 1 || !manifest.entries) return EMPTY_MANIFEST
return { version: 1, entries: manifest.entries }
}
async function writeManifest(manifest: FontCacheManifest) {
const { BaseDirectory, mkdir, writeFile } = await import('@tauri-apps/plugin-fs')
await mkdir(CACHE_DIR, { baseDir: BaseDirectory.AppLocalData, recursive: true })
await writeFile(MANIFEST_PATH, textEncoder.encode(JSON.stringify(manifest)), {
baseDir: BaseDirectory.AppLocalData
})
await writeCacheJson(MANIFEST_PATH, manifest)
}
export async function downloadedFontCacheSummary(): Promise<DownloadedFontCacheSummary> {
@ -86,64 +75,39 @@ export async function downloadedFontCacheSummary(): Promise<DownloadedFontCacheS
}
export async function clearDownloadedFontCache(): Promise<void> {
const { BaseDirectory, remove } = await import('@tauri-apps/plugin-fs')
try {
await remove(CACHE_DIR, { baseDir: BaseDirectory.AppLocalData, recursive: true })
} catch (error) {
console.warn('Downloaded font cache clear skipped:', error)
}
await removeCachePrefix(CACHE_DIR)
}
export function createTauriDownloadedFontCache(): DownloadedFontCache {
return {
async read(family, style) {
if (!isTauriRuntime()) return null
try {
const { BaseDirectory, readFile } = await import('@tauri-apps/plugin-fs')
const manifest = await readManifest()
const entry = manifest.entries[await cacheKey(family, style)]
if (!entry) return null
const manifest = await readManifest()
const entry = manifest.entries[await cacheKey(family, style)]
if (!entry) return null
const data = await readFile(`${CACHE_DIR}/${entry.file}`, {
baseDir: BaseDirectory.AppLocalData
})
const buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
if (buffer.byteLength !== entry.byteLength) return null
if ((await hashBytes(buffer)) !== entry.sha256) return null
return buffer
} catch (error) {
if (isUnavailableTauriRuntimeError(error)) return null
throw error
}
const buffer = await readCacheBytes(`${FILE_DIR}/${entry.file}`)
if (!buffer) return null
if (buffer.byteLength !== entry.byteLength) return null
if ((await hashBytes(buffer)) !== entry.sha256) return null
return buffer
},
async write(family, style, data) {
if (!isTauriRuntime()) return
try {
const { BaseDirectory, mkdir, writeFile } = await import('@tauri-apps/plugin-fs')
await mkdir(CACHE_DIR, { baseDir: BaseDirectory.AppLocalData, recursive: true })
const key = await cacheKey(family, style)
const sha256 = await hashBytes(data)
const file = `${key}.ttf`
await writeCacheBytes(`${FILE_DIR}/${file}`, data)
const key = await cacheKey(family, style)
const sha256 = await hashBytes(data)
const file = `${key}.ttf`
await writeFile(`${CACHE_DIR}/${file}`, new Uint8Array(data), {
baseDir: BaseDirectory.AppLocalData
})
const manifest = await readManifest()
manifest.entries[key] = {
family,
style,
file,
byteLength: data.byteLength,
sha256,
updatedAt: Date.now()
}
await writeManifest(manifest)
} catch (error) {
if (isUnavailableTauriRuntimeError(error)) return
throw error
const manifest = await readManifest()
manifest.entries[key] = {
family,
style,
file,
byteLength: data.byteLength,
sha256,
updatedAt: Date.now()
}
await writeManifest(manifest)
}
}
}

View file

@ -20,6 +20,7 @@ import {
import IconChevronRight from '~icons/lucide/chevron-right'
import { vTestId, useI18n } from '@open-pencil/vue'
import AppShortcutText from '@/components/ui/AppShortcutText.vue'
import { useMenuUI } from '@/components/ui/menu'
import { IS_TAURI } from '@/constants'
import { useAppMenu } from '@/app/shell/menu/app-menu'
@ -127,9 +128,9 @@ const subMenuCls = useMenuUI({ content: 'min-w-44' })
@select="runMenuAction(sub)"
>
<span class="flex-1">{{ menuLabel(sub) }}</span>
<span v-if="menuShortcut(sub)" class="text-[11px] text-muted">{{
<AppShortcutText v-if="menuShortcut(sub)">{{
menuShortcut(sub)
}}</span>
}}</AppShortcutText>
</MenubarItem>
</template>
</MenubarSubContent>
@ -153,9 +154,9 @@ const subMenuCls = useMenuUI({ content: 'min-w-44' })
@select="runMenuAction(item)"
>
<span class="flex-1">{{ menuLabel(item) }}</span>
<span v-if="menuShortcut(item)" class="text-[11px] text-muted">{{
<AppShortcutText v-if="menuShortcut(item)">{{
menuShortcut(item)
}}</span>
}}</AppShortcutText>
</MenubarItem>
</template>
</MenubarContent>

View file

@ -15,9 +15,9 @@ import { useI18n } from '@open-pencil/vue'
import { nodeIcon } from '@/app/editor/icons'
import { useEditorStore } from '@/app/editor/active-store'
import { openExternalLink } from '@/app/shell/ui'
import AppInput from '@/components/ui/AppInput.vue'
import { useButtonUI } from '@/components/ui/button'
import { useDialogUI } from '@/components/ui/dialog'
import { useInputUI } from '@/components/ui/input'
import Tip from '@/components/ui/Tip.vue'
type LocalAsset = {
@ -41,7 +41,6 @@ const selectedAssetId = ref<string | null>(null)
const previewUrl = ref<string | null>(null)
const previewLoading = ref(false)
let previewRequestId = 0
const input = useInputUI({ size: 'sm' })
const insertButton = useButtonUI({ tone: 'ghost', size: 'iconSm' })
const primaryButton = useButtonUI({ tone: 'accent', size: 'md' })
const dialog = useDialogUI({ content: 'flex w-[720px] max-w-[92vw] flex-col overflow-hidden' })
@ -179,11 +178,11 @@ function insertSelectedAsset() {
{{ panels.assets }}
</header>
<div class="shrink-0 px-2 pb-2">
<input
<AppInput
v-model="query"
data-test-id="assets-search"
:class="input.base"
type="search"
test-id="assets-search"
size="sm"
:placeholder="panels.searchLocalComponents"
/>
</div>

View file

@ -32,6 +32,7 @@ import { appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
import { createCanvasMenuActions } from '@/app/editor/canvas/menu/actions'
import { useCanvasContextMenu } from '@/app/editor/canvas/menu/context'
import { canvasMenuItemClass, canvasMenuShortcutClass } from '@/app/editor/canvas/menu/model'
import AppShortcutText from '@/components/ui/AppShortcutText.vue'
import { menu, useMenuUI } from '@/components/ui/menu'
const store = useEditorStore()
@ -88,7 +89,7 @@ function contextCommandIcon(id: EditorCommandId | undefined): Component | undefi
@select="execCommand('copy')"
>
<span>{{ t.copy }}</span
><span class="text-[11px] text-muted">{{ appMenuShortcutLabel('copy') }}</span>
><AppShortcutText>{{ appMenuShortcutLabel('copy') }}</AppShortcutText>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-cut"
@ -97,11 +98,11 @@ function contextCommandIcon(id: EditorCommandId | undefined): Component | undefi
@select="execCommand('cut')"
>
<span>{{ t.cut }}</span
><span class="text-[11px] text-muted">{{ appMenuShortcutLabel('cut') }}</span>
><AppShortcutText>{{ appMenuShortcutLabel('cut') }}</AppShortcutText>
</ContextMenuItem>
<ContextMenuItem data-test-id="context-paste" :class="cls.item" @select="execCommand('paste')">
<span>{{ t.pasteHere }}</span
><span class="text-[11px] text-muted">{{ appMenuShortcutLabel('paste') }}</span>
><AppShortcutText>{{ appMenuShortcutLabel('paste') }}</AppShortcutText>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-paste-to-replace"
@ -118,9 +119,9 @@ function contextCommandIcon(id: EditorCommandId | undefined): Component | undefi
@select="getCommand('selection.duplicate').run()"
>
<span>{{ getCommand('selection.duplicate').label }}</span
><span class="text-[11px] text-muted">{{
><AppShortcutText>{{
formatShortcut(editorCommandMetadata('selection.duplicate').shortcut)
}}</span>
}}</AppShortcutText>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-delete"
@ -129,9 +130,7 @@ function contextCommandIcon(id: EditorCommandId | undefined): Component | undefi
@select="getCommand('selection.delete').run()"
>
<span>{{ getCommand('selection.delete').label }}</span
><span class="text-[11px] text-muted">{{
editorCommandMetadata('selection.delete').shortcut
}}</span>
><AppShortcutText>{{ editorCommandMetadata('selection.delete').shortcut }}</AppShortcutText>
</ContextMenuItem>
<template v-for="(item, i) in contextMenu" :key="`menu-${i}`">
@ -160,7 +159,7 @@ function contextCommandIcon(id: EditorCommandId | undefined): Component | undefi
/>
<span class="truncate">{{ sub.label }}</span>
</span>
<span v-if="sub.shortcut" class="text-[11px] text-muted">{{ sub.shortcut }}</span>
<AppShortcutText v-if="sub.shortcut">{{ sub.shortcut }}</AppShortcutText>
</template>
</ContextMenuItem>
</ContextMenuSubContent>

View file

@ -10,6 +10,7 @@ import { activeTab } from '@/app/tabs'
import AcpPermissionDialog from '@/components/chat/AcpPermissionDialog.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import AppTextButton from '@/components/ui/AppTextButton.vue'
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
import { useAIChat } from '@/app/ai/chat/use'
import { toast } from '@/app/shell/ui'
@ -185,31 +186,31 @@ function handleClearChat() {
v-if="messages.length > 0"
class="flex shrink-0 items-center gap-1 border-t border-border px-3 py-1"
>
<button
<AppTextButton
v-if="IS_DEV"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
:ui="{ base: 'flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-hover' }"
@click="handleCopyDebug"
>
<icon-lucide-clipboard-copy v-if="!debugCopied" class="size-3" />
<icon-lucide-check v-else class="size-3 text-green-400" />
{{ debugCopied ? 'Copied' : 'Copy log' }}
</button>
<button
</AppTextButton>
<AppTextButton
v-if="IS_DEV && hasAcpDebugEntries()"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
:ui="{ base: 'flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-hover' }"
@click="handleCopyAcpLog"
>
<icon-lucide-bug v-if="!acpLogCopied" class="size-3" />
<icon-lucide-check v-else class="size-3 text-green-400" />
{{ acpLogCopied ? 'Copied' : 'ACP log' }}
</button>
<button
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
</AppTextButton>
<AppTextButton
:ui="{ base: 'flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-hover' }"
@click="handleClearChat"
>
<icon-lucide-trash-2 class="size-3" />
Clear
</button>
</AppTextButton>
</div>
<ChatInput :status="status" @submit="handleSubmit" @stop="handleStop" />

View file

@ -9,6 +9,7 @@ import { JSX_REFERENCE, selectionToJSX } from '@open-pencil/core/design-jsx'
import { useI18n, useSceneComputed } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import AppTextButton from '@/components/ui/AppTextButton.vue'
import type { JSXFormat } from '@open-pencil/core/design-jsx'
@ -61,33 +62,33 @@ function copyReference() {
>
<div class="flex items-center gap-1.5">
<span class="text-[11px] text-muted">JSX</span>
<button
data-test-id="code-panel-format-toggle"
class="rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
<AppTextButton
test-id="code-panel-format-toggle"
:ui="{ base: 'rounded px-1.5 py-0.5 text-[11px] hover:bg-hover' }"
@click="toggleFormat"
>
{{ jsxFormat === 'openpencil' ? 'OpenPencil' : 'Tailwind' }}
</button>
</AppTextButton>
</div>
<div class="flex items-center gap-1">
<button
data-test-id="code-panel-copy-ref"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
<AppTextButton
test-id="code-panel-copy-ref"
:ui="{ base: 'flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] hover:bg-hover' }"
title="Copy JSX prop reference to clipboard"
@click="copyReference"
>
<icon-lucide-check v-if="copiedRef" class="size-3 text-[var(--color-success)]" />
<icon-lucide-book-open v-else class="size-3" />
</button>
<button
data-test-id="code-panel-copy"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
</AppTextButton>
<AppTextButton
test-id="code-panel-copy"
:ui="{ base: 'flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] hover:bg-hover' }"
@click="copyCode"
>
<icon-lucide-check v-if="copied" class="size-3 text-[var(--color-success)]" />
<icon-lucide-copy v-else class="size-3" />
{{ copied ? dialogs.copied : dialogs.copy }}
</button>
</AppTextButton>
</div>
</div>
@ -112,4 +113,3 @@ function copyReference() {
</ScrollAreaRoot>
</div>
</template>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { selectTarget } from '@open-pencil/vue'
import AppInput from '@/components/ui/AppInput.vue'
import { useCollabPanelContext } from '@/components/CollabPanel/context'
const collab = useCollabPanelContext()
@ -8,11 +9,11 @@ const collab = useCollabPanelContext()
<template>
<div class="mb-3 text-xs font-medium text-surface">{{ collab.dialogs.roomLink }}</div>
<div class="mb-3 flex items-center gap-1.5">
<input
:value="collab.shareUrl"
<AppInput
:model-value="collab.shareUrl"
readonly
data-test-id="collab-room-link"
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface"
test-id="collab-room-link"
:ui="{ base: 'min-w-0 flex-1' }"
@focus="selectTarget($event)"
/>
<button

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import AppInput from '@/components/ui/AppInput.vue'
import { useCollabPanelContext } from '@/components/CollabPanel/context'
const collab = useCollabPanelContext()
@ -12,13 +13,12 @@ const collab = useCollabPanelContext()
<div class="mb-3">
<label class="mb-1 block text-xs text-muted">{{ collab.dialogs.yourName }}</label>
<input
<AppInput
v-model="collab.nameDraft"
data-test-id="collab-name-input"
class="w-full rounded border border-border bg-input px-2 py-1 text-xs text-surface"
test-id="collab-name-input"
:placeholder="collab.dialogs.enterYourName"
autofocus
@keydown.enter="collab.join"
@enter="collab.join"
/>
</div>

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import AppInput from '@/components/ui/AppInput.vue'
import { useCollabPanelContext } from '@/components/CollabPanel/context'
const collab = useCollabPanelContext()
@ -7,12 +8,11 @@ const collab = useCollabPanelContext()
<template>
<div class="mb-3">
<label class="mb-1 block text-xs text-muted">{{ collab.dialogs.yourName }}</label>
<input
<AppInput
v-model="collab.nameDraft"
data-test-id="collab-name-input"
class="w-full rounded border border-border bg-input px-2 py-1 text-xs text-surface"
test-id="collab-name-input"
:placeholder="collab.dialogs.enterYourName"
@keydown.enter="collab.share"
@enter="collab.share"
/>
</div>
@ -33,12 +33,12 @@ const collab = useCollabPanelContext()
</div>
<div class="flex items-center gap-1.5">
<input
<AppInput
v-model="collab.joinInput"
data-test-id="collab-join-input"
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface"
test-id="collab-join-input"
:placeholder="collab.dialogs.pasteRoomLinkOrId"
@keydown.enter="collab.join"
:ui="{ base: 'min-w-0 flex-1' }"
@enter="collab.join"
/>
<button
data-test-id="collab-join-room-button"

View file

@ -9,6 +9,7 @@ import {
import IconChevronDown from '~icons/lucide/chevron-down'
import AppShortcutText from '@/components/ui/AppShortcutText.vue'
import { menu } from '@/components/ui/menu'
import ToolButton from '@/components/Toolbar/ToolButton.vue'
import {
@ -105,9 +106,9 @@ function activeKeyForTool() {
>
<component :is="toolIcons[sub]" class="size-3.5" />
<span class="flex-1">{{ toolLabels[sub] }}</span>
<span v-if="!mobile && toolShortcuts[sub]" class="text-[11px] text-muted">
<AppShortcutText v-if="!mobile && toolShortcuts[sub]">
{{ toolShortcuts[sub] }}
</span>
</AppShortcutText>
</DropdownMenuItem>
</ToolbarItem>
</DropdownMenuContent>

View file

@ -10,6 +10,7 @@ import {
import { nextTick, ref, watch } from 'vue'
import { useEditorCommands, useI18n, formatShortcut } from '@open-pencil/vue'
import AppShortcutText from '@/components/ui/AppShortcutText.vue'
import { menuItem, useMenuUI } from '@/components/ui/menu'
import { useEditorStore } from '@/app/editor/active-store'
import { appMenuShortcut, appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
@ -127,15 +128,15 @@ watch(open, (v) => {
<DropdownMenuItem :class="itemCls" @select="zoomIn">
<span class="flex-1">{{ menuText.zoomIn }}</span>
<span class="text-[11px] text-muted">{{ appMenuShortcutLabel('zoom-in') }}</span>
<AppShortcutText>{{ appMenuShortcutLabel('zoom-in') }}</AppShortcutText>
</DropdownMenuItem>
<DropdownMenuItem :class="itemCls" @select="zoomOut">
<span class="flex-1">{{ menuText.zoomOut }}</span>
<span class="text-[11px] text-muted">{{ appMenuShortcutLabel('zoom-out') }}</span>
<AppShortcutText>{{ appMenuShortcutLabel('zoom-out') }}</AppShortcutText>
</DropdownMenuItem>
<DropdownMenuItem :class="itemCls" @select="getCommand('view.zoomFit').run()">
<span class="flex-1">{{ commands.zoomToFit }}</span>
<span class="text-[11px] text-muted">{{ appMenuShortcutLabel('view.zoomFit') }}</span>
<AppShortcutText>{{ appMenuShortcutLabel('view.zoomFit') }}</AppShortcutText>
</DropdownMenuItem>
<DropdownMenuItem
v-for="preset in ZOOM_PRESETS"
@ -145,9 +146,9 @@ watch(open, (v) => {
>
<icon-lucide-check v-if="isActivePreset(preset.level)" class="absolute left-2 size-3.5" />
<span class="flex-1">{{ preset.label }}</span>
<span v-if="preset.shortcut" class="text-[11px] text-muted">{{
<AppShortcutText v-if="preset.shortcut">{{
formatShortcut(preset.shortcut)
}}</span>
}}</AppShortcutText>
</DropdownMenuItem>
<DropdownMenuSeparator :class="menuCls.separator" />

View file

@ -4,9 +4,9 @@ import { computed, ref } from 'vue'
import ProviderModelSelect from '@/components/chat/ProviderModelSelect.vue'
import ProviderSettings from '@/components/chat/ProviderSettings/ProviderSettings.vue'
import AppInput from '@/components/ui/AppInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { useButtonUI } from '@/components/ui/button'
import { useInputUI } from '@/components/ui/input'
import { useAIChat } from '@/app/ai/chat/use'
import { useI18n } from '@open-pencil/vue'
@ -35,9 +35,14 @@ const acpAgentName = computed(() => {
const isCustomProvider = computed(
() => providerID.value === 'openai-compatible' || providerID.value === 'anthropic-compatible'
)
const customModelName = computed(() => customModelID.value.trim())
const usesCustomModel = computed(
() => !!providerDef.value.supportsCustomModel && !!customModelName.value
)
const selectedModelName = computed(() => {
if (isCustomProvider.value) return customModelID.value || 'No model'
if (usesCustomModel.value) return customModelName.value
if (isCustomProvider.value) return 'No model'
return providerDef.value.models.find((m) => m.id === modelID.value)?.name ?? modelID.value
})
@ -61,7 +66,7 @@ function handleSubmit(e: Event) {
{{ acpAgentName }}
</div>
</template>
<template v-else-if="isCustomProvider">
<template v-else-if="isCustomProvider || usesCustomModel">
<div
class="flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-muted"
data-test-id="chat-custom-model-label"
@ -81,12 +86,11 @@ function handleSubmit(e: Event) {
<!-- Input form -->
<form class="flex gap-1.5" @submit="handleSubmit">
<input
<AppInput
v-model="input"
type="text"
data-test-id="chat-input"
test-id="chat-input"
:placeholder="dialogs.describeChange"
:class="useInputUI({ ui: { base: 'min-w-0 flex-1 placeholder:text-muted' } }).base"
:ui="{ base: 'min-w-0 flex-1 placeholder:text-muted' }"
:disabled="isStreaming"
@paste.stop
@copy.stop

View file

@ -9,6 +9,7 @@ import {
SelectViewport
} from 'reka-ui'
import AppBadge from '@/components/ui/AppBadge.vue'
import { useSelectUI } from '@/components/ui/select'
import { useAIChat } from '@/app/ai/chat/use'
@ -37,9 +38,7 @@ const selectCls = useSelectUI({
:class="selectCls.item"
>
<SelectItemText class="flex-1">{{ model.name }}</SelectItemText>
<span v-if="model.tag" class="rounded bg-accent/10 px-1 py-px text-[9px] text-accent">
{{ model.tag }}
</span>
<AppBadge v-if="model.tag">{{ model.tag }}</AppBadge>
</SelectItem>
</SelectViewport>
</SelectContent>

View file

@ -1,8 +1,7 @@
<script setup lang="ts">
import { useI18n } from '@open-pencil/vue'
import { openExternalLink } from '@/app/shell/ui'
import { useInputUI } from '@/components/ui/input'
import ProviderSettingsKeyField from '@/components/chat/ProviderSettings/ProviderSettingsKeyField.vue'
import { useProviderSettingsContext } from '@/components/chat/ProviderSettings/context'
const ctx = useProviderSettingsContext()
@ -10,35 +9,17 @@ const { dialogs } = useI18n()
</script>
<template>
<div v-if="!ctx.isACP" class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">{{ dialogs.apiKey }}</label>
<button
v-if="ctx.apiKey"
class="cursor-pointer text-[10px] text-muted hover:text-surface"
data-test-id="provider-settings-clear-key"
@click="ctx.clearKey"
>
{{ dialogs.clear }}
</button>
</div>
<input
v-model="ctx.keyInput"
type="password"
data-test-id="provider-settings-api-key"
:placeholder="
ctx.hasExistingKey ? dialogs.keySavedReplace : ctx.providerDef.keyPlaceholder
"
:class="useInputUI({ size: 'sm' }).base"
@change="ctx.save"
/>
<button
v-if="ctx.providerDef.keyURL"
type="button"
class="cursor-pointer text-[9px] text-muted underline hover:text-surface"
@click="openExternalLink(ctx.providerDef.keyURL as string)"
>
{{ dialogs.getAPIKeyGeneric }}
</button>
</div>
<ProviderSettingsKeyField
v-if="!ctx.isACP"
v-model="ctx.keyInput"
:label="dialogs.apiKey"
:saved="!!ctx.apiKey"
clear-test-id="provider-settings-clear-key"
input-test-id="provider-settings-api-key"
:placeholder="ctx.hasExistingKey ? dialogs.keySavedReplace : ctx.providerDef.keyPlaceholder"
:key-url="ctx.providerDef.keyURL"
:key-url-label="dialogs.getAPIKeyGeneric"
@clear="ctx.clearKey"
@change="ctx.save"
/>
</template>

View file

@ -2,6 +2,7 @@
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import ProviderSettingsField from '@/components/chat/ProviderSettings/ProviderSettingsField.vue'
import { useProviderSettingsContext } from '@/components/chat/ProviderSettings/context'
const ctx = useProviderSettingsContext()
@ -9,8 +10,10 @@ const { dialogs } = useI18n()
</script>
<template>
<div v-if="!ctx.isACP && ctx.providerID === 'openai-compatible'" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">{{ dialogs.apiType }}</label>
<ProviderSettingsField
v-if="!ctx.isACP && ctx.providerID === 'openai-compatible'"
:label="dialogs.apiType"
>
<TabsRoot
:model-value="ctx.customAPIType"
data-test-id="provider-settings-api-type"
@ -34,5 +37,5 @@ const { dialogs } = useI18n()
<TabsContent value="completions" />
<TabsContent value="responses" />
</TabsRoot>
</div>
</ProviderSettingsField>
</template>

View file

@ -1,37 +1,52 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from '@open-pencil/vue'
import { useInputUI } from '@/components/ui/input'
import { listProviderModels } from '@/app/ai/chat/provider-models'
import AppComboboxInput from '@/components/ui/AppComboboxInput.vue'
import ProviderSettingsField from '@/components/chat/ProviderSettings/ProviderSettingsField.vue'
import ProviderSettingsInput from '@/components/chat/ProviderSettings/ProviderSettingsInput.vue'
import { useProviderSettingsContext } from '@/components/chat/ProviderSettings/context'
const ctx = useProviderSettingsContext()
const { dialogs } = useI18n()
const suggestedModels = ref<Array<{ id: string; name: string }>>([])
const showModelSuggestions = computed(() => ctx.providerID === 'openrouter')
async function loadModelSuggestions() {
if (!showModelSuggestions.value || suggestedModels.value.length) return
suggestedModels.value = await listProviderModels(ctx.providerID)
}
</script>
<template>
<template v-if="!ctx.isACP">
<div v-if="ctx.providerDef.supportsCustomBaseURL" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">{{ dialogs.baseURL }}</label>
<input
<ProviderSettingsField v-if="ctx.providerDef.supportsCustomBaseURL" :label="dialogs.baseURL">
<ProviderSettingsInput
v-model="ctx.baseURLInput"
type="text"
data-test-id="provider-settings-base-url"
test-id="provider-settings-base-url"
placeholder="http://localhost:11434/v1"
:class="useInputUI({ size: 'sm' }).base"
@change="ctx.save"
/>
</div>
</ProviderSettingsField>
<div v-if="ctx.providerDef.supportsCustomModel" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">{{ dialogs.modelID }}</label>
<input
<ProviderSettingsField v-if="ctx.providerDef.supportsCustomModel" :label="dialogs.modelID">
<AppComboboxInput
v-if="showModelSuggestions"
v-model="ctx.customModelInput"
type="text"
data-test-id="provider-settings-custom-model"
test-id="provider-settings-custom-model"
:options="suggestedModels.map((model) => ({ value: model.id, label: model.name }))"
placeholder="e.g. meta-llama/llama-3.3-70b-instruct"
@focusin="loadModelSuggestions"
@update:model-value="ctx.save"
/>
<ProviderSettingsInput
v-else
v-model="ctx.customModelInput"
test-id="provider-settings-custom-model"
placeholder="e.g. llama-3.3-70b"
:class="useInputUI({ size: 'sm' }).base"
@change="ctx.save"
/>
</div>
</ProviderSettingsField>
</template>
</template>

View file

@ -1,7 +1,8 @@
<script setup lang="ts">
import { useI18n } from '@open-pencil/vue'
import { useInputUI } from '@/components/ui/input'
import ProviderSettingsField from '@/components/chat/ProviderSettings/ProviderSettingsField.vue'
import ProviderSettingsInput from '@/components/chat/ProviderSettings/ProviderSettingsInput.vue'
import { useProviderSettingsContext } from '@/components/chat/ProviderSettings/context'
const ctx = useProviderSettingsContext()
@ -9,16 +10,14 @@ const { dialogs } = useI18n()
</script>
<template>
<div v-if="!ctx.isACP" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">{{ dialogs.maxOutputTokens }}</label>
<input
<ProviderSettingsField v-if="!ctx.isACP" :label="dialogs.maxOutputTokens">
<ProviderSettingsInput
v-model.number="ctx.maxOutputTokens"
type="number"
data-test-id="provider-settings-max-tokens"
test-id="provider-settings-max-tokens"
:min="1024"
:max="128000"
:step="1024"
:class="useInputUI({ size: 'sm' }).base"
/>
</div>
</ProviderSettingsField>
</template>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import AppTextButton from '@/components/ui/AppTextButton.vue'
import type { TestIdProps } from '@open-pencil/vue'
interface ProviderSettingsFieldProps extends TestIdProps {
label: string
clearLabel?: string
}
const { label, clearLabel, testId } = defineProps<ProviderSettingsFieldProps>()
const emit = defineEmits<{ clear: [] }>()
</script>
<template>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">{{ label }}</label>
<AppTextButton v-if="clearLabel" :test-id="testId" @click="emit('clear')">
{{ clearLabel }}
</AppTextButton>
</div>
<slot />
<slot name="hint" />
</div>
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import type { TestIdProps } from '@open-pencil/vue'
import AppInput from '@/components/ui/AppInput.vue'
interface ProviderSettingsInputProps extends TestIdProps {
type?: 'text' | 'password' | 'number'
placeholder?: string
min?: number
max?: number
step?: number
}
const {
type = 'text',
placeholder,
testId,
min,
max,
step
} = defineProps<ProviderSettingsInputProps>()
const modelValue = defineModel<string | number>({ required: true })
const emit = defineEmits<{ change: [] }>()
</script>
<template>
<AppInput
v-model="modelValue"
:type="type"
:test-id="testId"
:placeholder="placeholder"
:min="min"
:max="max"
:step="step"
size="sm"
@change="emit('change')"
/>
</template>

View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import { useI18n } from '@open-pencil/vue'
import ProviderSettingsField from '@/components/chat/ProviderSettings/ProviderSettingsField.vue'
import ProviderSettingsInput from '@/components/chat/ProviderSettings/ProviderSettingsInput.vue'
import ProviderSettingsLink from '@/components/chat/ProviderSettings/ProviderSettingsLink.vue'
const { label, modelValue, saved, clearTestId, inputTestId, placeholder, keyUrl, keyUrlLabel } =
defineProps<{
label: string
modelValue: string
saved: boolean
clearTestId: string
inputTestId: string
placeholder: string
keyUrl?: string
keyUrlLabel?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
change: []
clear: []
}>()
const { dialogs } = useI18n()
</script>
<template>
<ProviderSettingsField
:label="label"
:clear-label="saved ? dialogs.clear : undefined"
:test-id="clearTestId"
@clear="emit('clear')"
>
<ProviderSettingsInput
:model-value="modelValue"
type="password"
:test-id="inputTestId"
:placeholder="placeholder"
@update:model-value="emit('update:modelValue', String($event))"
@change="emit('change')"
/>
<template #hint>
<ProviderSettingsLink v-if="keyUrl && keyUrlLabel" :href="keyUrl">
{{ keyUrlLabel }}
</ProviderSettingsLink>
</template>
</ProviderSettingsField>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import { openExternalLink } from '@/app/shell/ui'
import AppTextButton from '@/components/ui/AppTextButton.vue'
const { href } = defineProps<{ href: string }>()
</script>
<template>
<AppTextButton size="xs" underline @click="openExternalLink(href)">
<slot />
</AppTextButton>
</template>

View file

@ -1,8 +1,7 @@
<script setup lang="ts">
import { useI18n } from '@open-pencil/vue'
import { openExternalLink } from '@/app/shell/ui'
import { useInputUI } from '@/components/ui/input'
import ProviderSettingsKeyField from '@/components/chat/ProviderSettings/ProviderSettingsKeyField.vue'
import { useProviderSettingsContext } from '@/components/chat/ProviderSettings/context'
const ctx = useProviderSettingsContext()
@ -10,65 +9,33 @@ const { dialogs } = useI18n()
</script>
<template>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">{{ dialogs.pexelsAPIKey }}</label>
<button
v-if="ctx.pexelsApiKey"
class="cursor-pointer text-[10px] text-muted hover:text-surface"
data-test-id="provider-settings-clear-pexels-key"
@click="ctx.clearPexelsKey"
>
{{ dialogs.clear }}
</button>
</div>
<input
v-model="ctx.pexelsKeyInput"
type="password"
data-test-id="provider-settings-pexels-key"
:placeholder="
ctx.hasExistingPexelsKey ? dialogs.keySavedReplace : dialogs.stockPhotoToolOptional
"
:class="useInputUI({ size: 'sm' }).base"
@change="ctx.save"
/>
<button
type="button"
class="cursor-pointer text-[9px] text-muted underline hover:text-surface"
@click="openExternalLink('https://www.pexels.com/api/')"
>
{{ dialogs.getPexelsAPIKey }}
</button>
</div>
<ProviderSettingsKeyField
v-model="ctx.pexelsKeyInput"
:label="dialogs.pexelsAPIKey"
:saved="!!ctx.pexelsApiKey"
clear-test-id="provider-settings-clear-pexels-key"
input-test-id="provider-settings-pexels-key"
:placeholder="
ctx.hasExistingPexelsKey ? dialogs.keySavedReplace : dialogs.stockPhotoToolOptional
"
key-url="https://www.pexels.com/api/"
:key-url-label="dialogs.getPexelsAPIKey"
@clear="ctx.clearPexelsKey"
@change="ctx.save"
/>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">{{ dialogs.unsplashAccessKey }}</label>
<button
v-if="ctx.unsplashAccessKey"
class="cursor-pointer text-[10px] text-muted hover:text-surface"
data-test-id="provider-settings-clear-unsplash-key"
@click="ctx.clearUnsplashKey"
>
{{ dialogs.clear }}
</button>
</div>
<input
v-model="ctx.unsplashKeyInput"
type="password"
data-test-id="provider-settings-unsplash-key"
:placeholder="
ctx.hasExistingUnsplashKey ? dialogs.keySavedReplace : dialogs.pexelsAlternativeOptional
"
:class="useInputUI({ size: 'sm' }).base"
@change="ctx.save"
/>
<button
type="button"
class="cursor-pointer text-[9px] text-muted underline hover:text-surface"
@click="openExternalLink('https://unsplash.com/oauth/applications')"
>
{{ dialogs.getUnsplashAccessKey }}
</button>
</div>
<ProviderSettingsKeyField
v-model="ctx.unsplashKeyInput"
:label="dialogs.unsplashAccessKey"
:saved="!!ctx.unsplashAccessKey"
clear-test-id="provider-settings-clear-unsplash-key"
input-test-id="provider-settings-unsplash-key"
:placeholder="
ctx.hasExistingUnsplashKey ? dialogs.keySavedReplace : dialogs.pexelsAlternativeOptional
"
key-url="https://unsplash.com/oauth/applications"
:key-url-label="dialogs.getUnsplashAccessKey"
@clear="ctx.clearUnsplashKey"
@change="ctx.save"
/>
</template>

View file

@ -2,7 +2,8 @@
import { computed, ref } from 'vue'
import ProviderSelectField from '@/components/chat/ProviderSelect/ProviderSelectField.vue'
import { useInputUI } from '@/components/ui/input'
import AppInput from '@/components/ui/AppInput.vue'
import AppTextButton from '@/components/ui/AppTextButton.vue'
import { useAIChat } from '@/app/ai/chat/use'
import { ACP_AGENTS } from '@open-pencil/core/constants'
import { openExternalLink } from '@/app/shell/ui'
@ -44,32 +45,27 @@ function save() {
<form v-if="!isACP" class="flex w-full flex-col gap-2" @submit.prevent="save">
<ProviderSelectField test-id="provider-selector" />
<!-- Base URL (OpenAI-compatible only) -->
<input
<!-- Base URL (compatible providers only) -->
<AppInput
v-if="providerDef.supportsCustomBaseURL"
v-model="baseURLInput"
type="text"
data-test-id="provider-base-url"
test-id="provider-base-url"
:placeholder="dialogs.baseURLPlaceholder"
:class="useInputUI().base"
/>
<!-- Custom model ID (OpenAI-compatible only) -->
<input
v-if="providerDef.supportsCustomModel"
<!-- Optional custom model ID for providers that support arbitrary model IDs. -->
<AppInput
v-if="providerDef.supportsCustomModel && providerID !== 'openrouter'"
v-model="customModelInput"
type="text"
data-test-id="provider-custom-model"
test-id="provider-custom-model"
:placeholder="dialogs.modelIDPlaceholder"
:class="useInputUI().base"
/>
<input
<AppInput
v-model="keyInput"
type="password"
data-test-id="api-key-input"
test-id="api-key-input"
:placeholder="providerDef.keyPlaceholder"
:class="useInputUI().base"
/>
<button
@ -105,15 +101,15 @@ function save() {
</p>
</div>
<button
<AppTextButton
v-if="!isACP && providerDef.keyURL"
type="button"
data-test-id="api-key-get-link"
class="mt-2.5 cursor-pointer text-[10px] text-muted underline hover:text-surface"
test-id="api-key-get-link"
underline
:ui="{ base: 'mt-2.5' }"
@click="openExternalLink(providerDef.keyURL as string)"
>
{{ dialogs.getAPIKey({ provider: providerDef.name }) }}
</button>
</AppTextButton>
<p
v-if="providerID === 'openrouter'"

View file

@ -6,6 +6,7 @@ import { useAppearance, useI18n } from '@open-pencil/vue'
import ScrubInput from '@/components/ScrubInput.vue'
import VariableScrubInput from '@/components/properties/VariableScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { useIconButtonUI } from '@/components/ui/icon-button'
import { useSectionUI } from '@/components/ui/section'
const { panels } = useI18n()
@ -126,8 +127,10 @@ function onToggleCorners() {
<Tip :label="panels.independentCornerRadii">
<button
data-test-id="independent-corners-toggle"
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
:class="{ '!border-accent !text-accent': showIndependentCorners }"
:class="[
useIconButtonUI({ size: 'md', ui: { base: 'size-[26px] shrink-0' } }).base,
{ '!border-accent !text-accent': showIndependentCorners }
]"
@click="onToggleCorners"
>
<svg

View file

@ -177,8 +177,10 @@ function setGap(stroke: Stroke | undefined, patch: StrokePatch, value: number) {
<Tip :label="panels.strokeSides">
<button
data-test-id="stroke-sides-toggle"
class="flex size-[26px] shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-input text-muted hover:bg-hover hover:text-surface"
:class="{ '!border-accent !text-accent': expandedSides }"
:class="[
useIconButtonUI({ size: 'md', ui: { base: 'size-[26px] shrink-0' } }).base,
{ '!border-accent !text-accent': expandedSides }
]"
@click="onToggleSides(activeNode!)"
>
<svg class="size-3.5" viewBox="0 0 14 14" fill="currentColor">

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { twMerge } from 'tailwind-merge'
const { ui } = defineProps<{
ui?: {
base?: string
}
}>()
const cls = computed(() =>
twMerge('shrink-0 rounded bg-accent/10 px-1 py-px text-[9px] text-accent', ui?.base)
)
</script>
<template>
<span :class="cls"><slot /></span>
</template>

View file

@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxViewport,
type AcceptableValue
} from 'reka-ui'
import type { TestIdProps } from '@open-pencil/vue'
import AppBadge from '@/components/ui/AppBadge.vue'
import { useInputUI } from '@/components/ui/input'
import { useSelectUI } from '@/components/ui/select'
export type AppComboboxOption = {
value: string
label: string
meta?: string
}
interface AppComboboxInputProps extends TestIdProps {
options: AppComboboxOption[]
placeholder?: string
ui?: {
input?: string
content?: string
item?: string
viewport?: string
empty?: string
}
}
const {
options,
placeholder,
testId = 'app-combobox-input',
ui
} = defineProps<AppComboboxInputProps>()
const modelValue = defineModel<string>({ required: true })
const open = ref(false)
const select = useSelectUI({
content: ui?.content ?? 'max-h-56 min-w-[var(--reka-combobox-trigger-width)]',
item: ui?.item ?? 'gap-2 rounded px-2 py-1.5 text-[11px]'
})
const inputClass = computed(() => useInputUI({ size: 'sm', ui: { base: ui?.input } }).base)
const viewportClass = ui?.viewport ?? 'max-h-56 overflow-y-auto p-0.5'
const emptyClass = ui?.empty ?? 'px-2 py-2 text-[11px] text-muted'
const filteredOptions = computed(() => {
const query = modelValue.value.trim().toLowerCase()
if (!query) return options.slice(0, 50)
return options
.filter((option) => {
const value = option.value.toLowerCase()
const label = option.label.toLowerCase()
return value.includes(query) || label.includes(query)
})
.slice(0, 50)
})
function updateValue(value: AcceptableValue) {
if (typeof value !== 'string') return
modelValue.value = value
}
</script>
<template>
<ComboboxRoot
v-model:open="open"
:model-value="modelValue"
:ignore-filter="true"
open-on-focus
@update:model-value="updateValue"
>
<ComboboxInput
:model-value="modelValue"
:display-value="() => modelValue"
type="text"
:data-test-id="testId"
:placeholder="placeholder"
:class="inputClass"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
@update:model-value="updateValue"
/>
<ComboboxPortal>
<ComboboxContent
v-if="filteredOptions.length || options.length"
position="popper"
:side-offset="2"
:class="select.content"
>
<ComboboxViewport :class="viewportClass">
<ComboboxItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
:class="select.item"
>
<div class="min-w-0 flex-1">
<div class="truncate text-surface">{{ option.label }}</div>
<div class="truncate font-mono text-[10px] text-muted">{{ option.value }}</div>
</div>
<AppBadge v-if="option.meta">{{ option.meta }}</AppBadge>
</ComboboxItem>
<div v-if="filteredOptions.length === 0" :class="emptyClass">No matching models</div>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import type { TestIdProps } from '@open-pencil/vue'
import { useInputUI } from '@/components/ui/input'
interface AppInputProps extends TestIdProps {
type?: 'text' | 'password' | 'number' | 'search'
placeholder?: string
readonly?: boolean
disabled?: boolean
autofocus?: boolean
min?: number
max?: number
step?: number
ui?: {
base?: string
}
size?: 'sm' | 'md'
}
const {
type = 'text',
placeholder,
readonly,
disabled,
autofocus,
min,
max,
step,
ui,
size = 'md',
testId
} = defineProps<AppInputProps>()
const modelValue = defineModel<string | number>({ required: true })
const emit = defineEmits<{
change: []
enter: [event: KeyboardEvent]
focus: [event: FocusEvent]
}>()
</script>
<template>
<input
v-model="modelValue"
:type="type"
:data-test-id="testId"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:autofocus="autofocus"
:min="min"
:max="max"
:step="step"
:class="useInputUI({ size, ui }).base"
@change="emit('change')"
@keydown.enter="emit('enter', $event)"
@focus="emit('focus', $event)"
/>
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { twMerge } from 'tailwind-merge'
const { ui } = defineProps<{
ui?: {
base?: string
}
}>()
const cls = computed(() => twMerge('text-[11px] text-muted', ui?.base))
</script>
<template>
<span :class="cls"><slot /></span>
</template>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import { twMerge } from 'tailwind-merge'
import type { TestIdProps } from '@open-pencil/vue'
interface AppTextButtonProps extends TestIdProps {
ui?: {
base?: string
}
size?: 'xs' | 'sm'
underline?: boolean
}
const { ui, size = 'sm', underline = false, testId } = defineProps<AppTextButtonProps>()
const emit = defineEmits<{ click: [event: MouseEvent] }>()
const cls = computed(() =>
twMerge(
'cursor-pointer text-muted hover:text-surface',
size === 'xs' ? 'text-[9px]' : 'text-[10px]',
underline && 'underline',
ui?.base
)
)
</script>
<template>
<button type="button" :data-test-id="testId" :class="cls" @click="emit('click', $event)">
<slot />
</button>
</template>

View file

@ -141,6 +141,7 @@ test('clicking AI tab shows provider setup when no key set', async () => {
await chatTab().click()
await expect(apiKeyInput()).toBeVisible()
await expect(page.getByText('Connect an AI provider to start chatting.')).toBeVisible()
await expect(page.getByTestId('provider-custom-model')).toBeHidden()
})
test('saving API key shows chat interface', async () => {
@ -190,7 +191,8 @@ test('model selector is visible and clickable', async () => {
await expect(page.getByText('Best for design')).toBeVisible()
await expect(page.getByText('Free').first()).toBeVisible()
await page.keyboard.press('Escape')
await page.getByRole('option', { name: /Claude Sonnet 4\.6/ }).click()
await expect(page.getByRole('option', { name: /Claude Sonnet 4\.6/ })).toBeHidden()
})
test('tool calls render in assistant message', async () => {
@ -220,6 +222,26 @@ test('switching tabs preserves chat', async () => {
await expect(page.getByText('Hello there', { exact: true })).toBeVisible({ timeout: 10000 })
})
test('OpenRouter accepts a custom model ID from provider settings', async () => {
const customModel = 'meta-llama/llama-3.3-70b-instruct'
await page.keyboard.press('Escape')
await page.getByTestId('provider-settings-trigger').click()
const customModelInput = page.getByTestId('provider-settings-custom-model')
await expect(customModelInput).toBeVisible()
await customModelInput.fill(customModel)
await page.getByTestId('provider-settings-done').click()
await expect(page.getByTestId('chat-custom-model-label')).toContainText(customModel)
await expect(page.getByTestId('chat-model-selector')).toBeHidden()
await page.getByTestId('provider-settings-trigger').click()
await page.getByTestId('provider-settings-custom-model').fill('')
await page.getByTestId('provider-settings-done').click()
await expect(page.getByTestId('chat-model-selector')).toBeVisible()
})
test('transport errors show an actionable toast', async () => {
await chatInput().fill('Trigger missing agent error')
await chatInput().press('Enter')

View file

@ -0,0 +1,48 @@
import { describe, expect, test } from 'bun:test'
import { resolveLanguageModelID } from '@/app/ai/chat/model'
import { normalizeOpenRouterModel } from '@/app/ai/chat/provider-models'
describe('resolveLanguageModelID', () => {
test('uses the selected OpenRouter model when no custom model is configured', () => {
expect(
resolveLanguageModelID({
providerID: 'openrouter',
modelID: 'anthropic/claude-sonnet-4.6',
customModelID: ''
})
).toBe('anthropic/claude-sonnet-4.6')
})
test('uses a custom OpenRouter model ID when provided', () => {
expect(
resolveLanguageModelID({
providerID: 'openrouter',
modelID: 'anthropic/claude-sonnet-4.6',
customModelID: ' meta-llama/llama-3.3-70b-instruct '
})
).toBe('meta-llama/llama-3.3-70b-instruct')
})
})
describe('normalizeOpenRouterModel', () => {
test('keeps tool-capable OpenRouter models', () => {
expect(
normalizeOpenRouterModel({
id: 'meta-llama/llama-3.3-70b-instruct',
name: 'Llama 3.3 70B Instruct',
supported_parameters: ['tools']
})
).toEqual({ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B Instruct' })
})
test('skips OpenRouter models without tool support', () => {
expect(
normalizeOpenRouterModel({
id: 'text-only/model',
name: 'Text Only',
supported_parameters: []
})
).toBeNull()
})
})

View file

@ -0,0 +1,59 @@
import { afterEach, describe, expect, test } from 'bun:test'
function installLocalStorage() {
const data = new Map<string, string>()
const storage = {
get length() {
return data.size
},
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => data.set(key, value),
removeItem: (key: string) => data.delete(key),
key: (index: number) => [...data.keys()][index] ?? null
} satisfies Pick<Storage, 'length' | 'getItem' | 'setItem' | 'removeItem' | 'key'>
const storageProp = ['local', 'Storage'].join('')
Object.assign(globalThis, { window: Object.fromEntries([[storageProp, storage]]) })
return data
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
})
describe('app cache', () => {
test('stores text in the web cache namespace', async () => {
const storage = installLocalStorage()
const { readCacheText, writeCacheText } = await import('@/app/cache')
await writeCacheText('providers/models', 'cached')
expect(storage.get('open-pencil:cache:v1:providers/models')).toBe('cached')
await expect(readCacheText('providers/models')).resolves.toBe('cached')
})
test('expires JSON values by max age', async () => {
installLocalStorage()
const { readCacheJson, writeCacheJson } = await import('@/app/cache')
await writeCacheJson('json/key', { ok: true })
await expect(readCacheJson('json/key', 60_000)).resolves.toEqual({ ok: true })
await expect(readCacheJson('json/key', -1)).resolves.toBeNull()
})
test('removes a web cache prefix', async () => {
installLocalStorage()
const { readCacheText, removeCachePrefix, writeCacheText } = await import('@/app/cache')
await writeCacheText('openrouter/models', 'models')
await writeCacheText('openrouter/other', 'other')
await writeCacheText('fonts/manifest', 'fonts')
await removeCachePrefix('openrouter')
await expect(readCacheText('openrouter/models')).resolves.toBeNull()
await expect(readCacheText('openrouter/other')).resolves.toBeNull()
await expect(readCacheText('fonts/manifest')).resolves.toBe('fonts')
})
})