fix(ai): allow custom OpenRouter models
This commit is contained in:
parent
8fa34fb0b8
commit
8cb6d008fc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
74
src/app/ai/chat/provider-models.ts
Normal file
74
src/app/ai/chat/provider-models.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
129
src/app/cache/index.ts
vendored
Normal 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 }))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
18
src/components/ui/AppBadge.vue
Normal file
18
src/components/ui/AppBadge.vue
Normal 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>
|
||||
120
src/components/ui/AppComboboxInput.vue
Normal file
120
src/components/ui/AppComboboxInput.vue
Normal 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>
|
||||
60
src/components/ui/AppInput.vue
Normal file
60
src/components/ui/AppInput.vue
Normal 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>
|
||||
16
src/components/ui/AppShortcutText.vue
Normal file
16
src/components/ui/AppShortcutText.vue
Normal 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>
|
||||
32
src/components/ui/AppTextButton.vue
Normal file
32
src/components/ui/AppTextButton.vue
Normal 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>
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
48
tests/engine/app/ai/model.test.ts
Normal file
48
tests/engine/app/ai/model.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
59
tests/engine/app/cache.test.ts
Normal file
59
tests/engine/app/cache.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue