refactor: read stored settings and external responses with Valibot (#937)
* refactor(settings): read stored preferences with a Valibot schema Each field falls back to its default on its own, so one bad value keeps the rest, as before. Tests pin the behavior; they pass against the previous implementation too. * refactor(mcp): read stored MCP connections with a Valibot schema A connection schema checks the ID, trimmed name, transport, and URL rule; a bearer token is kept only for the connection's own credential. A new test covers the credential check and normalization, and passes against the previous implementation. * refactor(clipboard): read Figma's image batch response with a Valibot schema * refactor(mcp): read tool descriptors with a Valibot schema The descriptor type now follows the schema, and tests cover what a descriptor must contain. * refactor(ai): read stored model settings with Valibot schemas Connections and model profiles are schemas with their defaults and bounds; the thinking-level migration and the checks between connections, models, and role assignments stay as code. Characterization tests written against the previous implementation pin the behavior.
This commit is contained in:
parent
9b4100b1d5
commit
2f79278df1
|
|
@ -1,33 +1,11 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import type { ToolCapability } from '@open-pencil/core/tools'
|
||||
export type { ToolCapability } from '@open-pencil/core/tools'
|
||||
|
||||
export type ToolEffect = 'read' | 'write'
|
||||
export type ToolAvailability = 'default' | 'eval' | 'filesystem'
|
||||
export interface ToolDescriptor {
|
||||
name: string
|
||||
description: string
|
||||
effect: ToolEffect
|
||||
availability: ToolAvailability
|
||||
capabilities: ToolCapability[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ToolPolicy {
|
||||
allowEval: boolean
|
||||
disabledTools: string[]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
const TOOL_EFFECTS: ReadonlySet<string> = new Set<ToolEffect>(['read', 'write'])
|
||||
const TOOL_AVAILABILITIES: ReadonlySet<string> = new Set<ToolAvailability>([
|
||||
'default',
|
||||
'eval',
|
||||
'filesystem'
|
||||
])
|
||||
const TOOL_CAPABILITIES: ReadonlySet<string> = new Set<ToolCapability>([
|
||||
const TOOL_EFFECTS = ['read', 'write'] as const
|
||||
const TOOL_AVAILABILITIES = ['default', 'eval', 'filesystem'] as const
|
||||
const TOOL_CAPABILITIES = [
|
||||
'document:read',
|
||||
'document:write',
|
||||
'filesystem:read',
|
||||
|
|
@ -36,30 +14,28 @@ const TOOL_CAPABILITIES: ReadonlySet<string> = new Set<ToolCapability>([
|
|||
'code:execute',
|
||||
'settings:read',
|
||||
'settings:write'
|
||||
])
|
||||
] as const satisfies readonly ToolCapability[]
|
||||
|
||||
export type ToolEffect = (typeof TOOL_EFFECTS)[number]
|
||||
export type ToolAvailability = (typeof TOOL_AVAILABILITIES)[number]
|
||||
|
||||
const toolDescriptorSchema = v.object({
|
||||
name: v.pipe(v.string(), v.minLength(1)),
|
||||
description: v.string(),
|
||||
effect: v.picklist(TOOL_EFFECTS),
|
||||
availability: v.picklist(TOOL_AVAILABILITIES),
|
||||
capabilities: v.array(v.picklist(TOOL_CAPABILITIES)),
|
||||
enabled: v.boolean()
|
||||
})
|
||||
|
||||
export type ToolDescriptor = v.InferOutput<typeof toolDescriptorSchema>
|
||||
|
||||
export interface ToolPolicy {
|
||||
allowEval: boolean
|
||||
disabledTools: string[]
|
||||
}
|
||||
|
||||
export function parseToolDescriptor(value: unknown): ToolDescriptor | null {
|
||||
if (!isRecord(value)) return null
|
||||
const { name, description, effect, availability, capabilities, enabled } = value
|
||||
if (typeof name !== 'string' || !name) return null
|
||||
if (typeof description !== 'string') return null
|
||||
if (typeof effect !== 'string' || !TOOL_EFFECTS.has(effect)) return null
|
||||
if (typeof availability !== 'string' || !TOOL_AVAILABILITIES.has(availability)) return null
|
||||
if (typeof enabled !== 'boolean') return null
|
||||
if (
|
||||
!Array.isArray(capabilities) ||
|
||||
capabilities.some(
|
||||
(capability) => typeof capability !== 'string' || !TOOL_CAPABILITIES.has(capability)
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
effect: effect as ToolEffect,
|
||||
availability: availability as ToolAvailability,
|
||||
capabilities: capabilities as ToolCapability[],
|
||||
enabled
|
||||
}
|
||||
const parsed = v.safeParse(toolDescriptorSchema, value)
|
||||
return parsed.success ? parsed.output : null
|
||||
}
|
||||
|
|
|
|||
30
packages/mcp/tests/tool/metadata.test.ts
Normal file
30
packages/mcp/tests/tool/metadata.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { parseToolDescriptor, type ToolDescriptor } from '@open-pencil/mcp/tools'
|
||||
|
||||
const descriptor = {
|
||||
name: 'export_image',
|
||||
description: 'Exports a node as an image',
|
||||
effect: 'read',
|
||||
availability: 'filesystem',
|
||||
capabilities: ['document:read', 'filesystem:write'],
|
||||
enabled: true
|
||||
} satisfies ToolDescriptor
|
||||
|
||||
describe('parseToolDescriptor', () => {
|
||||
test('reads a descriptor the server reports', () => {
|
||||
expect(parseToolDescriptor({ ...descriptor, extra: 1 })).toEqual(descriptor)
|
||||
})
|
||||
|
||||
test('rejects a descriptor with a field the app cannot act on', () => {
|
||||
for (const change of [
|
||||
{ name: '' },
|
||||
{ effect: 'delete' },
|
||||
{ availability: 'always' },
|
||||
{ capabilities: ['document:read', 'shell:run'] },
|
||||
{ enabled: 'true' }
|
||||
]) {
|
||||
expect(parseToolDescriptor({ ...descriptor, ...change })).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { uniq } from 'es-toolkit'
|
||||
import * as v from 'valibot'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
|
|
@ -25,7 +27,6 @@ import {
|
|||
type AIModelSettings,
|
||||
type OptionalAIModelRole,
|
||||
type ResolvedAIModelRole,
|
||||
type HarnessPermissionMode,
|
||||
type ThinkingLevel
|
||||
} from '@/app/ai/models/types'
|
||||
|
||||
|
|
@ -33,10 +34,6 @@ const LEGACY_CONNECTION_ID = 'connection-default'
|
|||
const LEGACY_MODEL_ID: AIModelProfileId = 'model-default'
|
||||
const DEFAULT_MAX_OUTPUT_TOKENS = 16_384
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isProviderID(value: unknown): value is AIProviderID {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
|
|
@ -44,78 +41,81 @@ function isProviderID(value: unknown): value is AIProviderID {
|
|||
)
|
||||
}
|
||||
|
||||
function isAPIType(value: unknown): value is 'completions' | 'responses' {
|
||||
return value === 'completions' || value === 'responses'
|
||||
}
|
||||
const connectionSchema = v.object({
|
||||
id: v.pipe(v.string(), v.minLength(1)),
|
||||
providerID: v.custom<AIProviderID>(isProviderID),
|
||||
customBaseURL: v.fallback(v.string(), ''),
|
||||
customAPIType: v.fallback(v.picklist(['completions', 'responses']), 'completions'),
|
||||
credentialProfileId: v.pipe(v.string(), v.minLength(1))
|
||||
})
|
||||
|
||||
function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
||||
return typeof value === 'string' && THINKING_LEVELS.includes(value as ThinkingLevel)
|
||||
}
|
||||
const thinkingLevelSchema = v.picklist(THINKING_LEVELS)
|
||||
|
||||
/** Profiles saved before thinking levels kept a Pi level or a free-text provider effort. */
|
||||
function parseThinkingLevel(value: Record<string, unknown>): ThinkingLevel {
|
||||
if (isThinkingLevel(value.thinkingLevel)) return value.thinkingLevel
|
||||
if (isThinkingLevel(value.harnessThinkingLevel)) return value.harnessThinkingLevel
|
||||
const effort = stringValue(value.reasoningEffort).trim().toLowerCase()
|
||||
if (effort === 'none') return 'off'
|
||||
if (effort === 'max') return 'xhigh'
|
||||
return isThinkingLevel(effort) ? effort : 'default'
|
||||
function migrateThinkingLevel(stored: {
|
||||
thinkingLevel?: unknown
|
||||
harnessThinkingLevel?: unknown
|
||||
reasoningEffort?: unknown
|
||||
}): ThinkingLevel {
|
||||
for (const value of [stored.thinkingLevel, stored.harnessThinkingLevel]) {
|
||||
if (v.is(thinkingLevelSchema, value)) return value
|
||||
}
|
||||
const effort = typeof stored.reasoningEffort === 'string' ? stored.reasoningEffort : ''
|
||||
const normalized = effort.trim().toLowerCase()
|
||||
if (normalized === 'none') return 'off'
|
||||
if (normalized === 'max') return 'xhigh'
|
||||
return v.is(thinkingLevelSchema, normalized) ? normalized : 'default'
|
||||
}
|
||||
|
||||
function isHarnessPermissionMode(value: unknown): value is HarnessPermissionMode {
|
||||
return (
|
||||
typeof value === 'string' && HARNESS_PERMISSION_MODES.includes(value as HarnessPermissionMode)
|
||||
)
|
||||
}
|
||||
|
||||
function isCapability(value: unknown): value is AIModelCapability {
|
||||
return value === 'tools' || value === 'vision'
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
const maxOutputTokensSchema = v.fallback(
|
||||
v.pipe(
|
||||
v.number(),
|
||||
v.finite(),
|
||||
v.transform((tokens) => Math.min(128_000, Math.max(1024, Math.round(tokens))))
|
||||
),
|
||||
DEFAULT_MAX_OUTPUT_TOKENS
|
||||
)
|
||||
|
||||
function normalizedMaxOutputTokens(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.min(128_000, Math.max(1024, Math.round(value)))
|
||||
: DEFAULT_MAX_OUTPUT_TOKENS
|
||||
return v.parse(maxOutputTokensSchema, value)
|
||||
}
|
||||
|
||||
const capabilitySchema = v.picklist(['tools', 'vision'])
|
||||
|
||||
const profileSchema = v.object({
|
||||
id: v.custom<AIModelProfileId>((id) => typeof id === 'string' && id.startsWith('model-')),
|
||||
name: v.fallback(v.string(), 'Model'),
|
||||
connectionId: v.string(),
|
||||
modelID: v.fallback(v.string(), ''),
|
||||
customModelID: v.fallback(v.string(), ''),
|
||||
maxOutputTokens: maxOutputTokensSchema,
|
||||
thinkingLevel: v.optional(v.unknown()),
|
||||
harnessThinkingLevel: v.optional(v.unknown()),
|
||||
reasoningEffort: v.optional(v.unknown()),
|
||||
harnessPermissionMode: v.fallback(v.optional(v.picklist(HARNESS_PERMISSION_MODES)), undefined),
|
||||
capabilities: v.fallback(
|
||||
v.pipe(
|
||||
v.array(v.unknown()),
|
||||
v.transform((capabilities) =>
|
||||
uniq(capabilities.filter((capability) => v.is(capabilitySchema, capability)))
|
||||
)
|
||||
),
|
||||
(): AIModelCapability[] => ['tools']
|
||||
)
|
||||
})
|
||||
|
||||
function parseConnection(value: unknown): AIModelConnection | null {
|
||||
if (!isRecord(value)) return null
|
||||
const id = stringValue(value.id)
|
||||
const credentialProfileId = stringValue(value.credentialProfileId)
|
||||
if (!id || !credentialProfileId || !isProviderID(value.providerID)) return null
|
||||
return {
|
||||
id,
|
||||
providerID: value.providerID,
|
||||
customBaseURL: stringValue(value.customBaseURL),
|
||||
customAPIType: isAPIType(value.customAPIType) ? value.customAPIType : 'completions',
|
||||
credentialProfileId
|
||||
}
|
||||
const parsed = v.safeParse(connectionSchema, value)
|
||||
return parsed.success ? parsed.output : null
|
||||
}
|
||||
|
||||
function parseProfile(value: unknown, connectionIds: Set<string>): AIModelProfile | null {
|
||||
if (!isRecord(value)) return null
|
||||
const id = stringValue(value.id)
|
||||
const connectionId = stringValue(value.connectionId)
|
||||
if (!id.startsWith('model-') || !connectionIds.has(connectionId)) return null
|
||||
const capabilities = Array.isArray(value.capabilities)
|
||||
? value.capabilities.filter(isCapability)
|
||||
: ['tools' as const]
|
||||
const parsed = v.safeParse(profileSchema, value)
|
||||
if (!parsed.success || !connectionIds.has(parsed.output.connectionId)) return null
|
||||
const { harnessThinkingLevel, reasoningEffort, ...profile } = parsed.output
|
||||
return {
|
||||
id: id as AIModelProfileId,
|
||||
name: stringValue(value.name, 'Model'),
|
||||
connectionId,
|
||||
modelID: stringValue(value.modelID),
|
||||
customModelID: stringValue(value.customModelID),
|
||||
maxOutputTokens: normalizedMaxOutputTokens(value.maxOutputTokens),
|
||||
thinkingLevel: parseThinkingLevel(value),
|
||||
harnessPermissionMode: isHarnessPermissionMode(value.harnessPermissionMode)
|
||||
? value.harnessPermissionMode
|
||||
: undefined,
|
||||
capabilities: [...new Set(capabilities)]
|
||||
...profile,
|
||||
thinkingLevel: migrateThinkingLevel({ ...profile, harnessThinkingLevel, reasoningEffort })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,23 +147,29 @@ function hydrateCuratedCapabilities(
|
|||
}
|
||||
}
|
||||
|
||||
const settingsSchema = v.object({
|
||||
version: v.literal(1),
|
||||
connections: v.fallback(v.array(v.unknown()), () => []),
|
||||
models: v.fallback(v.array(v.unknown()), () => []),
|
||||
assignments: v.fallback(v.record(v.string(), v.unknown()), () => ({}))
|
||||
})
|
||||
|
||||
/** Normalizes persisted settings, migrating fields from earlier versions. */
|
||||
export function parseAIModelSettings(value: unknown): AIModelSettings | null {
|
||||
if (!isRecord(value) || value.version !== 1) return null
|
||||
const connections = Array.isArray(value.connections)
|
||||
? value.connections.map(parseConnection).filter((connection) => connection !== null)
|
||||
: []
|
||||
const stored = v.safeParse(settingsSchema, value)
|
||||
if (!stored.success) return null
|
||||
const connections = stored.output.connections
|
||||
.map(parseConnection)
|
||||
.filter((connection) => connection !== null)
|
||||
const connectionIds = new Set(connections.map((connection) => connection.id))
|
||||
const models = Array.isArray(value.models)
|
||||
? value.models
|
||||
.map((profile) => parseProfile(profile, connectionIds))
|
||||
.filter((profile) => profile !== null)
|
||||
: []
|
||||
const models = stored.output.models
|
||||
.map((profile) => parseProfile(profile, connectionIds))
|
||||
.filter((profile) => profile !== null)
|
||||
if (!models.length) return null
|
||||
hydrateCuratedCapabilities(models, connections)
|
||||
const modelIds = new Set(models.map((profile) => profile.id))
|
||||
const rawAssignments = isRecord(value.assignments) ? value.assignments : {}
|
||||
const rawDesign = stringValue(rawAssignments.design)
|
||||
const rawAssignments = stored.output.assignments
|
||||
const rawDesign = typeof rawAssignments.design === 'string' ? rawAssignments.design : ''
|
||||
const design = rawDesign.startsWith('model-') ? (rawDesign as AIModelProfileId) : models[0].id
|
||||
const resolvedDesign = modelIds.has(design) ? design : models[0].id
|
||||
const assignments: AIModelSettings['assignments'] = {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import { tauriFetch } from '@/app/tauri/http'
|
||||
|
||||
type ClipboardImageFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
|
@ -7,27 +9,24 @@ type FigmaImageURLs = Record<string, string>
|
|||
const IMAGE_FETCH_CONCURRENCY = 6
|
||||
const IMAGE_FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
/** Figma's image batch response: a URL per image hash, unless it reports a failure. */
|
||||
const batchResponseSchema = v.pipe(
|
||||
v.object({
|
||||
error: v.optional(v.unknown()),
|
||||
status: v.optional(v.unknown()),
|
||||
meta: v.object({ s3_urls: v.record(v.string(), v.unknown()) })
|
||||
}),
|
||||
v.check(({ error, status }) => error !== true && (typeof status !== 'number' || status === 200))
|
||||
)
|
||||
|
||||
function imageURLsFromBatchResponse(value: unknown): FigmaImageURLs {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.error === true ||
|
||||
(typeof value.status === 'number' && value.status !== 200) ||
|
||||
!isRecord(value.meta)
|
||||
) {
|
||||
throw new Error('Figma returned an invalid image response')
|
||||
}
|
||||
const urls = value.meta.s3_urls
|
||||
if (!isRecord(urls)) throw new Error('Figma returned an invalid image URL map')
|
||||
|
||||
const result: FigmaImageURLs = {}
|
||||
for (const [hash, url] of Object.entries(urls)) {
|
||||
if (typeof url === 'string') result[hash] = url
|
||||
}
|
||||
return result
|
||||
const parsed = v.safeParse(batchResponseSchema, value)
|
||||
if (!parsed.success) throw new Error('Figma returned an invalid image response')
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed.output.meta.s3_urls).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function sha1Hex(bytes: Uint8Array): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { isEqual } from 'es-toolkit'
|
||||
import * as v from 'valibot'
|
||||
import { computed, ref, toRaw, watch } from 'vue'
|
||||
|
||||
import { appCredentialServices } from '@/app/settings/credentials/app'
|
||||
|
|
@ -17,61 +19,57 @@ export const MCP_CONNECTION_NAME_MAX_LENGTH = 80
|
|||
const MAX_URL_LENGTH = 2048
|
||||
const MCP_CONNECTION_ID_PATTERN = /^mcp-[a-z0-9._-]{1,60}$/
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isCredentialRef(value: unknown): value is CredentialRef {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.integrationId === 'string' &&
|
||||
typeof value.profileId === 'string' &&
|
||||
typeof value.field === 'string'
|
||||
)
|
||||
}
|
||||
const bearerSchema = v.object({
|
||||
type: v.literal('bearer'),
|
||||
credentialRef: v.object({ integrationId: v.string(), profileId: v.string(), field: v.string() })
|
||||
})
|
||||
|
||||
/** A stored bearer token counts only when it points at this connection's own credential. */
|
||||
function parseAuthentication(value: unknown, id: MCPConnectionID): MCPAuthentication {
|
||||
if (!isRecord(value) || value.type !== 'bearer') return { type: 'none' }
|
||||
const expectedReference = mcpConnectionCredentialRef(id)
|
||||
if (!isCredentialRef(value.credentialRef)) return { type: 'none' }
|
||||
const matchesExpected =
|
||||
value.credentialRef.integrationId === expectedReference.integrationId &&
|
||||
value.credentialRef.profileId === expectedReference.profileId &&
|
||||
value.credentialRef.field === expectedReference.field
|
||||
return matchesExpected ? { type: 'bearer', credentialRef: expectedReference } : { type: 'none' }
|
||||
const bearer = v.safeParse(bearerSchema, value)
|
||||
const expected = mcpConnectionCredentialRef(id)
|
||||
return bearer.success && isEqual(bearer.output.credentialRef, expected)
|
||||
? { type: 'bearer', credentialRef: expected }
|
||||
: { type: 'none' }
|
||||
}
|
||||
|
||||
const connectionSchema = v.object({
|
||||
id: v.pipe(v.string(), v.regex(MCP_CONNECTION_ID_PATTERN)),
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.minLength(1),
|
||||
v.transform((name) => name.slice(0, MCP_CONNECTION_NAME_MAX_LENGTH))
|
||||
),
|
||||
enabled: v.fallback(v.boolean(), false),
|
||||
transport: v.object({
|
||||
type: v.literal('streamable-http'),
|
||||
url: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.check((url) => isValidMCPConnectionURL(url))
|
||||
)
|
||||
}),
|
||||
authentication: v.optional(v.unknown())
|
||||
})
|
||||
|
||||
function parseConnection(value: unknown): MCPConnection | null {
|
||||
if (!isRecord(value) || !isRecord(value.transport)) return null
|
||||
const id = typeof value.id === 'string' ? value.id : ''
|
||||
const name = typeof value.name === 'string' ? value.name.trim() : ''
|
||||
const url = typeof value.transport.url === 'string' ? value.transport.url.trim() : ''
|
||||
if (!MCP_CONNECTION_ID_PATTERN.test(id) || !name || value.transport.type !== 'streamable-http') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
validateMCPConnectionURL(url)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const connectionId = id as MCPConnectionID
|
||||
return {
|
||||
id: connectionId,
|
||||
name: name.slice(0, MCP_CONNECTION_NAME_MAX_LENGTH),
|
||||
enabled: value.enabled === true,
|
||||
transport: { type: 'streamable-http', url },
|
||||
authentication: parseAuthentication(value.authentication, connectionId)
|
||||
}
|
||||
const parsed = v.safeParse(connectionSchema, value)
|
||||
if (!parsed.success) return null
|
||||
const { authentication, ...connection } = parsed.output
|
||||
const id = connection.id as MCPConnectionID
|
||||
return { ...connection, id, authentication: parseAuthentication(authentication, id) }
|
||||
}
|
||||
|
||||
const settingsSchema = v.object({ version: v.literal(1), connections: v.array(v.unknown()) })
|
||||
|
||||
export function parseMCPConnectionSettings(value: unknown): MCPConnectionSettings {
|
||||
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.connections)) {
|
||||
return { version: 1, connections: [] }
|
||||
}
|
||||
const settings = v.safeParse(settingsSchema, value)
|
||||
if (!settings.success) return { version: 1, connections: [] }
|
||||
const connections: MCPConnection[] = []
|
||||
const ids = new Set<string>()
|
||||
const names = new Set<string>()
|
||||
for (const candidate of value.connections) {
|
||||
for (const candidate of settings.output.connections) {
|
||||
const connection = parseConnection(candidate)
|
||||
if (!connection || ids.has(connection.id)) continue
|
||||
const normalizedName = connection.name.toLowerCase()
|
||||
|
|
@ -83,6 +81,15 @@ export function parseMCPConnectionSettings(value: unknown): MCPConnectionSetting
|
|||
return { version: 1, connections }
|
||||
}
|
||||
|
||||
function isValidMCPConnectionURL(url: string): boolean {
|
||||
try {
|
||||
validateMCPConnectionURL(url)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function validateMCPConnectionURL(value: string): URL {
|
||||
if (!value || value.length > MAX_URL_LENGTH) throw new Error('Enter a valid MCP server URL')
|
||||
let url: URL
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { uniq } from 'es-toolkit'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { DEFAULT_SNAPPING_PREFERENCES, type SnappingPreferences } from '@open-pencil/core/editor'
|
||||
|
||||
|
|
@ -29,7 +31,8 @@ export interface DesignCheckPreferences {
|
|||
}
|
||||
|
||||
/** Whether guided AI setup was offered and finished or skipped. */
|
||||
export type AISetupState = 'pending' | 'done'
|
||||
export const AI_SETUP_STATES = ['pending', 'done'] as const
|
||||
export type AISetupState = (typeof AI_SETUP_STATES)[number]
|
||||
|
||||
export interface AppPreferences {
|
||||
appearance: { animations: AnimationPreference }
|
||||
|
|
@ -73,105 +76,65 @@ export const DEFAULT_APP_PREFERENCES: Readonly<AppPreferences> = {
|
|||
|
||||
const STORAGE_KEY = 'open-pencil:preferences:v1'
|
||||
|
||||
function booleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback
|
||||
/** An object whose fields each fall back to their default, and that is all defaults when absent. */
|
||||
function section<TEntries extends v.ObjectEntries>(entries: TEntries) {
|
||||
const schema = v.object(entries)
|
||||
return v.fallback(schema, () => v.parse(schema, {}))
|
||||
}
|
||||
|
||||
interface StoredSnappingPreferences {
|
||||
geometry?: unknown
|
||||
objects?: unknown
|
||||
pixelGrid?: unknown
|
||||
}
|
||||
const defaults = DEFAULT_APP_PREFERENCES
|
||||
const snapping = defaults.editing.snapping
|
||||
|
||||
interface StoredAppPreferences {
|
||||
appearance?: { animations?: unknown }
|
||||
chat?: { reasoningDisplay?: unknown; maxAgentSteps?: unknown; changePreviewSize?: unknown }
|
||||
recovery?: { enabled?: unknown }
|
||||
editing?: { snapping?: StoredSnappingPreferences }
|
||||
rendering?: { canvasMode?: unknown }
|
||||
designCheck?: { showOnCanvas?: unknown; preset?: unknown; disabledRules?: unknown }
|
||||
onboarding?: { aiSetup?: unknown }
|
||||
}
|
||||
|
||||
function isStoredAppPreferences(value: unknown): value is StoredAppPreferences {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeAnimationPreference(value: unknown): AnimationPreference {
|
||||
return value === 'off' ? 'off' : 'system'
|
||||
}
|
||||
|
||||
function normalizeChatPreferences(chat: StoredAppPreferences['chat']): AppPreferences['chat'] {
|
||||
return {
|
||||
maxAgentSteps: resolveAgentStepLimit(chat?.maxAgentSteps),
|
||||
reasoningDisplay:
|
||||
chat?.reasoningDisplay === 'expanded' || chat?.reasoningDisplay === 'while-thinking'
|
||||
? chat.reasoningDisplay
|
||||
: 'collapsed',
|
||||
changePreviewSize: CHANGE_PREVIEW_SIZES.includes(chat?.changePreviewSize as ChangePreviewSize)
|
||||
? (chat?.changePreviewSize as ChangePreviewSize)
|
||||
: DEFAULT_APP_PREFERENCES.chat.changePreviewSize
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesignCheckPreferences(
|
||||
designCheck: StoredAppPreferences['designCheck']
|
||||
): DesignCheckPreferences {
|
||||
const preset = DESIGN_CHECK_PRESETS.find((candidate) => candidate === designCheck?.preset)
|
||||
const disabledRules = Array.isArray(designCheck?.disabledRules)
|
||||
? designCheck.disabledRules.filter((rule): rule is string => typeof rule === 'string')
|
||||
: []
|
||||
return {
|
||||
showOnCanvas: booleanOrDefault(
|
||||
designCheck?.showOnCanvas,
|
||||
DEFAULT_APP_PREFERENCES.designCheck.showOnCanvas
|
||||
/** Stored preferences, read field by field so one bad value keeps the rest. */
|
||||
const appPreferencesSchema = section({
|
||||
appearance: section({
|
||||
animations: v.fallback(v.picklist(ANIMATION_PREFERENCES), defaults.appearance.animations)
|
||||
}),
|
||||
chat: section({
|
||||
reasoningDisplay: v.fallback(v.picklist(REASONING_DISPLAYS), defaults.chat.reasoningDisplay),
|
||||
maxAgentSteps: v.fallback(
|
||||
v.pipe(v.unknown(), v.transform(resolveAgentStepLimit)),
|
||||
defaults.chat.maxAgentSteps
|
||||
),
|
||||
preset: preset ?? DEFAULT_APP_PREFERENCES.designCheck.preset,
|
||||
disabledRules: [...new Set(disabledRules)]
|
||||
}
|
||||
}
|
||||
changePreviewSize: v.fallback(v.picklist(CHANGE_PREVIEW_SIZES), defaults.chat.changePreviewSize)
|
||||
}),
|
||||
version: v.fallback(v.literal(1), 1),
|
||||
recovery: section({ enabled: v.fallback(v.boolean(), defaults.recovery.enabled) }),
|
||||
editing: section({
|
||||
snapping: section({
|
||||
geometry: v.fallback(v.boolean(), snapping.geometry),
|
||||
objects: v.fallback(v.boolean(), snapping.objects),
|
||||
pixelGrid: v.fallback(v.boolean(), snapping.pixelGrid)
|
||||
})
|
||||
}),
|
||||
rendering: section({
|
||||
canvasMode: v.fallback(v.picklist(CANVAS_RENDERING_MODES), defaults.rendering.canvasMode)
|
||||
}),
|
||||
designCheck: section({
|
||||
showOnCanvas: v.fallback(v.boolean(), defaults.designCheck.showOnCanvas),
|
||||
preset: v.fallback(v.picklist(DESIGN_CHECK_PRESETS), defaults.designCheck.preset),
|
||||
disabledRules: v.fallback(
|
||||
v.pipe(
|
||||
v.array(v.unknown()),
|
||||
v.transform((rules) => uniq(rules.filter((rule) => typeof rule === 'string')))
|
||||
),
|
||||
() => []
|
||||
)
|
||||
}),
|
||||
onboarding: section({
|
||||
aiSetup: v.fallback(v.picklist(AI_SETUP_STATES), defaults.onboarding.aiSetup)
|
||||
})
|
||||
})
|
||||
|
||||
function normalizePreferences(value: unknown): AppPreferences {
|
||||
const stored = isStoredAppPreferences(value) ? value : undefined
|
||||
const snapping = stored?.editing?.snapping
|
||||
|
||||
return {
|
||||
appearance: { animations: normalizeAnimationPreference(stored?.appearance?.animations) },
|
||||
chat: normalizeChatPreferences(stored?.chat),
|
||||
version: 1,
|
||||
recovery: {
|
||||
enabled: booleanOrDefault(stored?.recovery?.enabled, DEFAULT_APP_PREFERENCES.recovery.enabled)
|
||||
},
|
||||
editing: {
|
||||
snapping: {
|
||||
geometry: booleanOrDefault(
|
||||
snapping?.geometry,
|
||||
DEFAULT_APP_PREFERENCES.editing.snapping.geometry
|
||||
),
|
||||
objects: booleanOrDefault(
|
||||
snapping?.objects,
|
||||
DEFAULT_APP_PREFERENCES.editing.snapping.objects
|
||||
),
|
||||
pixelGrid: booleanOrDefault(
|
||||
snapping?.pixelGrid,
|
||||
DEFAULT_APP_PREFERENCES.editing.snapping.pixelGrid
|
||||
)
|
||||
}
|
||||
},
|
||||
rendering: {
|
||||
canvasMode: stored?.rendering?.canvasMode === 'tiled' ? 'tiled' : 'retained'
|
||||
},
|
||||
designCheck: normalizeDesignCheckPreferences(stored?.designCheck),
|
||||
onboarding: {
|
||||
aiSetup: stored?.onboarding?.aiSetup === 'done' ? 'done' : 'pending'
|
||||
}
|
||||
}
|
||||
/** Reads stored preferences, keeping every valid field and defaulting the rest. */
|
||||
export function parseAppPreferences(value: unknown): AppPreferences {
|
||||
return v.parse(appPreferencesSchema, value)
|
||||
}
|
||||
|
||||
export const appPreferences = useLocalStorage<AppPreferences>(
|
||||
STORAGE_KEY,
|
||||
structuredClone(DEFAULT_APP_PREFERENCES),
|
||||
{ mergeDefaults: (storageValue) => normalizePreferences(storageValue) }
|
||||
{ mergeDefaults: (storageValue) => parseAppPreferences(storageValue) }
|
||||
)
|
||||
|
||||
export function updateAnimationPreference(animations: AnimationPreference): void {
|
||||
|
|
|
|||
116
tests/app/ai/models/store.test.ts
Normal file
116
tests/app/ai/models/store.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { parseAIModelSettings } from '@/app/ai/models/store'
|
||||
import type { AIModelConnection } from '@/app/ai/models/types'
|
||||
|
||||
const connection = {
|
||||
id: 'connection-a',
|
||||
providerID: 'openrouter',
|
||||
customBaseURL: '',
|
||||
customAPIType: 'completions',
|
||||
credentialProfileId: 'default'
|
||||
} satisfies AIModelConnection
|
||||
|
||||
const profile = {
|
||||
id: 'model-a',
|
||||
name: 'Design',
|
||||
connectionId: 'connection-a',
|
||||
modelID: 'custom/model',
|
||||
customModelID: '',
|
||||
maxOutputTokens: 16_384,
|
||||
thinkingLevel: 'default',
|
||||
capabilities: ['tools']
|
||||
}
|
||||
|
||||
function settings(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
connections: [connection],
|
||||
models: [profile],
|
||||
assignments: { design: 'model-a', review: 'design', fast: null, vision: null },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseAIModelSettings', () => {
|
||||
test('rejects settings it cannot use', () => {
|
||||
for (const stored of [null, 'settings', { ...settings(), version: 2 }]) {
|
||||
expect(parseAIModelSettings(stored)).toBeNull()
|
||||
}
|
||||
expect(parseAIModelSettings(settings({ models: [] }))).toBeNull()
|
||||
})
|
||||
|
||||
test('drops connections and models that are malformed or point nowhere', () => {
|
||||
const parsed = parseAIModelSettings(
|
||||
settings({
|
||||
connections: [
|
||||
connection,
|
||||
{ ...connection, id: 'connection-b', providerID: 'unknown-provider' },
|
||||
{ ...connection, id: '' },
|
||||
'connection'
|
||||
],
|
||||
models: [
|
||||
profile,
|
||||
{ ...profile, id: 'model-b', connectionId: 'connection-b' },
|
||||
{ ...profile, id: 'profile-c' },
|
||||
null
|
||||
]
|
||||
})
|
||||
)
|
||||
expect(parsed?.connections.map((candidate) => candidate.id)).toEqual(['connection-a'])
|
||||
expect(parsed?.models.map((candidate) => candidate.id)).toEqual(['model-a'])
|
||||
})
|
||||
|
||||
test('fills and bounds the fields of a stored model', () => {
|
||||
const parsed = parseAIModelSettings(
|
||||
settings({
|
||||
connections: [{ ...connection, customAPIType: 'soap', customBaseURL: 7 }],
|
||||
models: [
|
||||
{
|
||||
id: 'model-a',
|
||||
connectionId: 'connection-a',
|
||||
maxOutputTokens: 1_000_000,
|
||||
harnessPermissionMode: 'everything',
|
||||
capabilities: ['vision', 'tools', 'vision', 'audio']
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
expect(parsed?.connections[0]).toEqual({
|
||||
...connection,
|
||||
customBaseURL: '',
|
||||
customAPIType: 'completions'
|
||||
})
|
||||
expect(parsed?.models[0]).toEqual({
|
||||
id: 'model-a',
|
||||
name: 'Model',
|
||||
connectionId: 'connection-a',
|
||||
modelID: '',
|
||||
customModelID: '',
|
||||
maxOutputTokens: 128_000,
|
||||
thinkingLevel: 'default',
|
||||
harnessPermissionMode: undefined,
|
||||
capabilities: ['vision', 'tools']
|
||||
})
|
||||
})
|
||||
|
||||
test('assigns roles only to models that can take them', () => {
|
||||
const parsed = parseAIModelSettings(
|
||||
settings({
|
||||
models: [profile, { ...profile, id: 'model-b', capabilities: ['tools', 'vision'] }],
|
||||
assignments: {
|
||||
design: 'model-missing',
|
||||
review: 'model-b',
|
||||
fast: 'model-x',
|
||||
vision: 'design'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(parsed?.assignments).toEqual({
|
||||
design: 'model-a',
|
||||
review: 'model-b',
|
||||
fast: null,
|
||||
vision: null
|
||||
})
|
||||
})
|
||||
})
|
||||
48
tests/app/settings/preferences/store.test.ts
Normal file
48
tests/app/settings/preferences/store.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { DEFAULT_APP_PREFERENCES, parseAppPreferences } from '@/app/settings/preferences/store'
|
||||
|
||||
describe('parseAppPreferences', () => {
|
||||
test('starts from the defaults when nothing usable is stored', () => {
|
||||
for (const stored of [undefined, null, 'preferences', [], {}]) {
|
||||
expect(parseAppPreferences(stored)).toEqual(DEFAULT_APP_PREFERENCES)
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps valid choices and defaults only the fields that are not', () => {
|
||||
const preferences = parseAppPreferences({
|
||||
appearance: { animations: 'off' },
|
||||
chat: { reasoningDisplay: 'sometimes', changePreviewSize: 'large', maxAgentSteps: 'many' },
|
||||
editing: { snapping: { geometry: false, objects: 'yes' } },
|
||||
rendering: 'tiled'
|
||||
})
|
||||
expect(preferences.appearance.animations).toBe('off')
|
||||
expect(preferences.chat).toEqual({
|
||||
...DEFAULT_APP_PREFERENCES.chat,
|
||||
changePreviewSize: 'large'
|
||||
})
|
||||
expect(preferences.editing.snapping).toEqual({
|
||||
...DEFAULT_APP_PREFERENCES.editing.snapping,
|
||||
geometry: false
|
||||
})
|
||||
expect(preferences.rendering).toEqual(DEFAULT_APP_PREFERENCES.rendering)
|
||||
})
|
||||
|
||||
test('keeps each disabled design check rule once and drops what is not a rule name', () => {
|
||||
const { designCheck } = parseAppPreferences({
|
||||
designCheck: { preset: 'strict', disabledRules: ['contrast', 7, 'contrast', 'spacing'] }
|
||||
})
|
||||
expect(designCheck).toEqual({
|
||||
showOnCanvas: true,
|
||||
preset: 'strict',
|
||||
disabledRules: ['contrast', 'spacing']
|
||||
})
|
||||
})
|
||||
|
||||
test('remembers that guided AI setup was finished or skipped', () => {
|
||||
expect(parseAppPreferences({ onboarding: { aiSetup: 'done' } }).onboarding.aiSetup).toBe('done')
|
||||
expect(parseAppPreferences({ onboarding: { aiSetup: 'later' } }).onboarding.aiSetup).toBe(
|
||||
'pending'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'bun:test'
|
|||
import {
|
||||
buildACPMCPServers,
|
||||
createMCPConnectionDraft,
|
||||
mcpConnectionCredentialRef,
|
||||
mcpConnectionSettingsSnapshot,
|
||||
parseMCPConnectionSettings,
|
||||
removeMCPConnection,
|
||||
|
|
@ -83,6 +84,35 @@ describe('MCP connections', () => {
|
|||
).toEqual(['mcp-valid'])
|
||||
})
|
||||
|
||||
test("keeps a stored token only when it is the connection's own credential", () => {
|
||||
const stored = (credentialRef: unknown) => ({
|
||||
version: 1,
|
||||
connections: [
|
||||
{
|
||||
id: 'mcp-tools',
|
||||
name: ' Tools ',
|
||||
enabled: 'yes',
|
||||
transport: { type: 'streamable-http', url: ' https://example.com/mcp ' },
|
||||
authentication: { type: 'bearer', credentialRef }
|
||||
}
|
||||
]
|
||||
})
|
||||
const own = mcpConnectionCredentialRef('mcp-tools')
|
||||
expect(parseMCPConnectionSettings(stored(own)).connections).toEqual([
|
||||
{
|
||||
id: 'mcp-tools',
|
||||
name: 'Tools',
|
||||
enabled: false,
|
||||
transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
|
||||
authentication: { type: 'bearer', credentialRef: own }
|
||||
}
|
||||
])
|
||||
const other = mcpConnectionCredentialRef('mcp-other')
|
||||
expect(parseMCPConnectionSettings(stored(other)).connections[0]?.authentication).toEqual({
|
||||
type: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
test('defaults new connections to disabled and reserves unique names', async () => {
|
||||
const draft = createMCPConnectionDraft()
|
||||
expect(draft.enabled).toBeFalse()
|
||||
|
|
|
|||
Loading…
Reference in a new issue