Merge branch 'refactor/settings-workflows' into refactor-ui-families
# Conflicts: # src/app/ai/models/settings/profile-editor/selection.ts
This commit is contained in:
commit
3fdac60afa
|
|
@ -8,16 +8,19 @@ import {
|
|||
type AIModelRole,
|
||||
type OptionalAIModelRole
|
||||
} from '@/app/ai/models'
|
||||
|
||||
export function useModelRoleAssignments(
|
||||
ai: Readonly<Ref<{ modelRoleUseDesign: string; noModel: string }>>
|
||||
) {
|
||||
const SAME_AS_DESIGN = '__design__'
|
||||
const NO_MODEL = '__none__'
|
||||
|
||||
function assignmentValue(role: AIModelRole): string {
|
||||
const assignment = aiModelSettings.value.assignments[role]
|
||||
if (assignment === null) return NO_MODEL
|
||||
return assignment === 'design' ? SAME_AS_DESIGN : assignment
|
||||
}
|
||||
|
||||
function optionsForRole(role: AIModelRole) {
|
||||
const profiles = aiModelSettings.value.models
|
||||
.filter((profile) => {
|
||||
|
|
@ -28,6 +31,7 @@ export function useModelRoleAssignments(
|
|||
})
|
||||
.map((profile) => ({ value: profile.id, label: profile.name }))
|
||||
if (role === 'design') return profiles
|
||||
|
||||
const design = modelProfile(aiModelSettings.value.assignments.design)
|
||||
const canInherit =
|
||||
!isAgentModelProfile(design) && (role !== 'vision' || design?.capabilities.includes('vision'))
|
||||
|
|
@ -37,6 +41,7 @@ export function useModelRoleAssignments(
|
|||
...profiles
|
||||
]
|
||||
}
|
||||
|
||||
function updateAssignment(role: AIModelRole, value: string): void {
|
||||
if (role === 'design') {
|
||||
const profile = modelProfile(value)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type AIModelProfileDraft
|
||||
} from '@/app/ai/models'
|
||||
import type { CredentialStatus } from '@/app/settings/credentials/types'
|
||||
|
||||
interface ConnectionOptions {
|
||||
draft: AIModelProfileDraft
|
||||
keyInput: Ref<string>
|
||||
|
|
@ -23,6 +24,7 @@ interface ConnectionOptions {
|
|||
isHarness: Readonly<Ref<boolean>>
|
||||
customModelSelected: Ref<boolean>
|
||||
}
|
||||
|
||||
export function useProfileConnection({
|
||||
draft,
|
||||
keyInput,
|
||||
|
|
@ -34,6 +36,7 @@ export function useProfileConnection({
|
|||
const keyStatus = ref<CredentialStatus>('missing')
|
||||
const connectionTestStatus = ref<'idle' | 'testing' | 'success' | 'error'>('idle')
|
||||
const connectionTestReason = ref<ProviderConnectionTestFailureReason | null>(null)
|
||||
|
||||
const hasExistingKey = computed(() => keyStatus.value === 'configured')
|
||||
const canTest = computed(() => {
|
||||
if (isACP.value || isHarness.value) return false
|
||||
|
|
@ -43,22 +46,27 @@ export function useProfileConnection({
|
|||
? Boolean(draft.customModelID.trim())
|
||||
: Boolean(draft.modelID.trim())
|
||||
})
|
||||
|
||||
function resetConnectionTest(): void {
|
||||
connectionTestStatus.value = 'idle'
|
||||
connectionTestReason.value = null
|
||||
}
|
||||
|
||||
async function refreshKeyStatus(): Promise<void> {
|
||||
const connection = findModelConnectionForDraft(draft)
|
||||
keyStatus.value = connection ? await modelConnectionCredentialStatus(connection.id) : 'missing'
|
||||
}
|
||||
|
||||
async function clearKey(): Promise<void> {
|
||||
const connection = findModelConnectionForDraft(draft)
|
||||
if (!connection) return
|
||||
|
||||
await setModelConnectionAPIKey(connection.id, '')
|
||||
await refreshAIProviderStatus()
|
||||
keyInput.value = ''
|
||||
await refreshKeyStatus()
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<void> {
|
||||
connectionTestStatus.value = 'testing'
|
||||
connectionTestReason.value = null
|
||||
|
|
@ -79,6 +87,7 @@ export function useProfileConnection({
|
|||
connectionTestStatus.value = 'error'
|
||||
connectionTestReason.value = result.reason
|
||||
}
|
||||
|
||||
return {
|
||||
keyStatus,
|
||||
connectionTestStatus,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { AIModelProfileDraft, AIModelCapability } from '@/app/ai/models'
|
|||
import { resolveModelsDevModel } from '@/app/ai/models/catalog'
|
||||
import { modelPickerOptions, type ModelPickerLabels } from '@/app/ai/models/picker/options'
|
||||
import { useProviderModelCatalog } from '@/app/ai/models/catalog/use'
|
||||
|
||||
export function useProfileModelSelection(
|
||||
draft: AIModelProfileDraft,
|
||||
ai: Readonly<Ref<ModelPickerLabels & { customModel: string }>>
|
||||
|
|
@ -21,6 +22,7 @@ export function useProfileModelSelection(
|
|||
Boolean(draft.customModelID.trim()) || draft.providerID === 'harness:pi'
|
||||
)
|
||||
const catalogModel = ref<ModelOption | null>(null)
|
||||
|
||||
const providerDef = computed(
|
||||
() => AI_PROVIDERS.find((provider) => provider.id === draft.providerID) ?? AI_PROVIDERS[0]
|
||||
)
|
||||
|
|
@ -34,6 +36,7 @@ export function useProfileModelSelection(
|
|||
)
|
||||
const providerDisplayName = computed(() => {
|
||||
if (!isACP.value) return providerDef.value.name
|
||||
|
||||
const agentID = draft.providerID.slice('acp:'.length)
|
||||
return ACP_AGENTS.find((agent) => agent.id === agentID)?.name ?? draft.providerID
|
||||
})
|
||||
|
|
@ -74,6 +77,7 @@ export function useProfileModelSelection(
|
|||
})
|
||||
const toolsEnabled = capabilityModel('tools')
|
||||
const visionEnabled = capabilityModel('vision')
|
||||
|
||||
function capabilityModel(capability: AIModelCapability) {
|
||||
return computed({
|
||||
get: () => draft.capabilities.includes(capability),
|
||||
|
|
@ -85,9 +89,11 @@ export function useProfileModelSelection(
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
function effectiveModelID(): string {
|
||||
return customModelSelected.value ? draft.customModelID.trim() : draft.modelID.trim()
|
||||
}
|
||||
|
||||
async function refreshCatalogModel(): Promise<void> {
|
||||
if (isACP.value) {
|
||||
catalogModel.value = null
|
||||
|
|
@ -97,14 +103,18 @@ export function useProfileModelSelection(
|
|||
const modelID = effectiveModelID()
|
||||
const resolved = await resolveModelsDevModel(providerID, modelID)
|
||||
if (providerID !== draft.providerID || modelID !== effectiveModelID()) return
|
||||
|
||||
catalogModel.value = resolved
|
||||
applyKnownModelMetadata()
|
||||
}
|
||||
|
||||
function applyKnownModelMetadata(): void {
|
||||
if (!knownModel.value) return
|
||||
|
||||
draft.capabilities = [...knownCapabilities.value]
|
||||
draft.maxOutputTokens = outputTokenRecommendation.value
|
||||
}
|
||||
|
||||
function updateProvider(providerID: AIProviderID): void {
|
||||
catalogModel.value = null
|
||||
draft.providerID = providerID
|
||||
|
|
@ -125,6 +135,7 @@ export function useProfileModelSelection(
|
|||
|
||||
void refreshCatalogModel()
|
||||
}
|
||||
|
||||
function updateModel(modelID: string): void {
|
||||
if (modelID === CUSTOM_MODEL_VALUE) {
|
||||
customModelSelected.value = true
|
||||
|
|
@ -141,13 +152,16 @@ export function useProfileModelSelection(
|
|||
void refreshCatalogModel()
|
||||
if (!draft.name.trim()) draft.name = modelDisplayName.value
|
||||
}
|
||||
|
||||
watch(
|
||||
() => draft.customModelID,
|
||||
() => {
|
||||
if (customModelSelected.value) void refreshCatalogModel()
|
||||
}
|
||||
)
|
||||
|
||||
void refreshCatalogModel()
|
||||
|
||||
return {
|
||||
providerDef,
|
||||
isACP,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ interface ProfileEditorOptions {
|
|||
|
||||
export function useModelProfileEditor({ profileId, keyInput, labels: ai }: ProfileEditorOptions) {
|
||||
const draft = reactive(createModelProfileDraft(profileId))
|
||||
|
||||
const selection = useProfileModelSelection(draft, ai)
|
||||
const {
|
||||
providerDef,
|
||||
|
|
@ -44,7 +45,9 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi
|
|||
visionEnabled,
|
||||
applyKnownModelMetadata
|
||||
} = selection
|
||||
|
||||
const saveError = ref<string | null>(null)
|
||||
|
||||
const {
|
||||
connectionTestStatus,
|
||||
connectionTestReason,
|
||||
|
|
@ -55,6 +58,7 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi
|
|||
clearKey,
|
||||
testConnection
|
||||
} = useProfileConnection({ draft, keyInput, providerDef, isACP, isHarness, customModelSelected })
|
||||
|
||||
const canDelete = computed(() => Boolean(profileId) && aiModelSettings.value.models.length > 1)
|
||||
const canSave = computed(
|
||||
() =>
|
||||
|
|
@ -64,18 +68,22 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi
|
|||
? Boolean(draft.customModelID.trim())
|
||||
: Boolean(draft.modelID.trim())))
|
||||
)
|
||||
|
||||
function updateProvider(id: AIProviderID) {
|
||||
selection.updateProvider(id)
|
||||
keyInput.value = ''
|
||||
resetConnectionTest()
|
||||
void refreshKeyStatus()
|
||||
}
|
||||
|
||||
function updateModel(id: string) {
|
||||
selection.updateModel(id)
|
||||
resetConnectionTest()
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
saveError.value = null
|
||||
|
||||
try {
|
||||
applyKnownModelMetadata()
|
||||
if (!draft.name.trim()) draft.name = modelDisplayName.value || providerDisplayName.value
|
||||
|
|
@ -91,8 +99,10 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(): Promise<boolean> {
|
||||
if (!profileId) return false
|
||||
|
||||
const profile = modelProfile(profileId)
|
||||
if (profile && modelConnectionUsageCount(profile.connectionId) === 1) {
|
||||
await setModelConnectionAPIKey(profile.connectionId, '')
|
||||
|
|
@ -102,11 +112,14 @@ export function useModelProfileEditor({ profileId, keyInput, labels: ai }: Profi
|
|||
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [draft.customBaseURL, draft.customModelID, draft.customAPIType, draft.modelID],
|
||||
resetConnectionTest
|
||||
)
|
||||
|
||||
void refreshKeyStatus()
|
||||
|
||||
return {
|
||||
draft,
|
||||
providerDef,
|
||||
|
|
|
|||
|
|
@ -5,13 +5,17 @@ import { ACP_AGENTS, AI_PROVIDERS } from '@open-pencil/core/constants'
|
|||
|
||||
import { aiModelSettings, modelConnection, modelConnectionCredentialStatus } from '@/app/ai/models'
|
||||
import type { CredentialStatus } from '@/app/settings/credentials/types'
|
||||
|
||||
export function useModelSettings() {
|
||||
let version = 0
|
||||
let disposed = false
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
disposed = true
|
||||
})
|
||||
|
||||
const statusByConnection = ref<Record<string, CredentialStatus>>({})
|
||||
|
||||
function providerName(providerID: string): string {
|
||||
if (providerID === 'harness:pi') return 'Pi'
|
||||
if (providerID.startsWith('acp:')) {
|
||||
|
|
@ -20,12 +24,14 @@ export function useModelSettings() {
|
|||
}
|
||||
return AI_PROVIDERS.find((provider) => provider.id === providerID)?.name ?? providerID
|
||||
}
|
||||
|
||||
const profiles = computed(() =>
|
||||
aiModelSettings.value.models.map((profile) => {
|
||||
const connection = modelConnection(profile.connectionId)
|
||||
const provider = AI_PROVIDERS.find((definition) => definition.id === connection?.providerID)
|
||||
const modelId = profile.customModelID || profile.modelID
|
||||
const modelName = provider?.models.find((model) => model.id === modelId)?.name || modelId
|
||||
|
||||
return {
|
||||
...profile,
|
||||
providerID: connection?.providerID ?? '',
|
||||
|
|
@ -34,6 +40,7 @@ export function useModelSettings() {
|
|||
}
|
||||
})
|
||||
)
|
||||
|
||||
async function refreshStatuses(): Promise<void> {
|
||||
const request = ++version
|
||||
const entries = await Promise.all(
|
||||
|
|
@ -44,6 +51,7 @@ export function useModelSettings() {
|
|||
)
|
||||
if (!disposed && request === version) statusByConnection.value = Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => aiModelSettings.value.connections.map((connection) => connection.id),
|
||||
() => void refreshStatuses(),
|
||||
|
|
|
|||
|
|
@ -9,17 +9,22 @@ import {
|
|||
} from '@/app/automation/mcp/preferences'
|
||||
import { refreshMCPRuntime, restartMCPRuntime } from '@/app/automation/mcp/runtime'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
|
||||
export function useMCPSettings() {
|
||||
const toolSearch = ref('')
|
||||
|
||||
const disabledToolNames = computed(() => new Set(disabledMCPTools.value))
|
||||
|
||||
function categoryStatus(effect: ToolEffect) {
|
||||
const tools = configurableMCPTools.value.filter((tool) => tool.effect === effect)
|
||||
const enabled = tools.filter((tool) => !disabledToolNames.value.has(tool.name)).length
|
||||
|
||||
return {
|
||||
enabled: enabled > 0,
|
||||
state: enabled > 0 && enabled < tools.length ? ('mixed' as const) : ('idle' as const)
|
||||
}
|
||||
}
|
||||
|
||||
const inspectionToolsStatus = computed(() => categoryStatus('read'))
|
||||
const modificationToolsStatus = computed(() => categoryStatus('write'))
|
||||
const enabledToolCount = computed(
|
||||
|
|
@ -34,21 +39,27 @@ export function useMCPSettings() {
|
|||
tool.name.toLowerCase().includes(query) || tool.description.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void refreshMCPRuntime()
|
||||
})
|
||||
|
||||
function restart(): void {
|
||||
void restartMCPRuntime()
|
||||
}
|
||||
|
||||
async function chooseRootDirectory(): Promise<void> {
|
||||
if (!isTauri()) return
|
||||
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const directory = await open({ directory: true, multiple: false })
|
||||
if (typeof directory === 'string') mcpRootDirectory.value = directory
|
||||
}
|
||||
|
||||
function isToolEnabled(name: string): boolean {
|
||||
return !disabledToolNames.value.has(name)
|
||||
}
|
||||
|
||||
function enableAllTools(): void {
|
||||
disabledMCPTools.value = []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,21 +8,27 @@ export function useRecentDiagnostics(
|
|||
refreshStats: () => Promise<void>
|
||||
) {
|
||||
const recentEvents = ref<DiagnosticEventSummary[]>([])
|
||||
|
||||
let version = 0
|
||||
let disposed = false
|
||||
|
||||
async function refresh() {
|
||||
const request = ++version
|
||||
const events = await diagnostics.list()
|
||||
if (!disposed && request === version) recentEvents.value = summarize(events.slice(0, 20))
|
||||
}
|
||||
|
||||
const unsubscribe = diagnostics.subscribe(() => {
|
||||
void refresh()
|
||||
void refreshStats()
|
||||
})
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
disposed = true
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
void refresh()
|
||||
|
||||
return { recentEvents }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ const vectorizeServices = { status: vectorizeCredentialStatus, set: setVectorize
|
|||
export function useVectorizeSettings(keyDraft: Ref<string>, services = vectorizeServices) {
|
||||
const keyStatus = ref<CredentialStatus>('missing')
|
||||
const error = ref('')
|
||||
|
||||
let version = 0
|
||||
let disposed = false
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
disposed = true
|
||||
version++
|
||||
})
|
||||
|
||||
const provider = computed(() =>
|
||||
VECTORIZE_PROVIDER_DEFINITIONS.find((definition) => definition.id === vectorizeProviderID.value)
|
||||
)
|
||||
|
|
@ -27,9 +30,11 @@ export function useVectorizeSettings(keyDraft: Ref<string>, services = vectorize
|
|||
value: definition.id,
|
||||
label: definition.name
|
||||
}))
|
||||
|
||||
async function refreshStatus(): Promise<void> {
|
||||
const request = ++version
|
||||
const id = vectorizeProviderID.value
|
||||
|
||||
try {
|
||||
const status = await services.status(id)
|
||||
if (!disposed && version === request) keyStatus.value = status
|
||||
|
|
@ -38,12 +43,15 @@ export function useVectorizeSettings(keyDraft: Ref<string>, services = vectorize
|
|||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCredential(clear: boolean): Promise<void> {
|
||||
const value = keyDraft.value
|
||||
if (!clear && !value.trim()) return
|
||||
|
||||
const request = ++version
|
||||
const id = vectorizeProviderID.value
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await services.set(id, clear ? '' : value)
|
||||
if (disposed || version !== request) return
|
||||
|
|
@ -54,6 +62,7 @@ export function useVectorizeSettings(keyDraft: Ref<string>, services = vectorize
|
|||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
vectorizeProviderID,
|
||||
() => {
|
||||
|
|
|
|||
22
src/app/integrations/mcp/mutations.ts
Normal file
22
src/app/integrations/mcp/mutations.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { MCPConnectionID } from './types'
|
||||
|
||||
// Domain-owned: pending operations survive disposal of a Settings editor.
|
||||
const pendingByConnection = new Map<MCPConnectionID, Promise<unknown>>()
|
||||
|
||||
export function enqueueMCPConnectionMutation<T>(
|
||||
id: MCPConnectionID,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const previous = pendingByConnection.get(id) ?? Promise.resolve()
|
||||
const pending = previous.then(operation, operation)
|
||||
const settled = pending.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
pendingByConnection.set(id, settled)
|
||||
void settled.then(() => {
|
||||
if (pendingByConnection.get(id) === settled) pendingByConnection.delete(id)
|
||||
return undefined
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
|
@ -12,6 +12,9 @@ import {
|
|||
} from '@/app/integrations/mcp'
|
||||
import type { CredentialStatus } from '@/app/settings/credentials/types'
|
||||
|
||||
import { enqueueMCPConnectionMutation } from '../mutations'
|
||||
import type { MCPConnectionID } from '../types'
|
||||
|
||||
const connectionServices = {
|
||||
status: mcpConnectionCredentialStatus,
|
||||
save: saveMCPConnectionDraft,
|
||||
|
|
@ -27,18 +30,23 @@ export function useMCPConnectionSettings(
|
|||
const draft = ref<MCPConnectionDraft>(createMCPConnectionDraft())
|
||||
const tokenStatus = ref<CredentialStatus>('missing')
|
||||
const error = ref('')
|
||||
|
||||
let version = 0
|
||||
let disposed = false
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
disposed = true
|
||||
version++
|
||||
})
|
||||
|
||||
const savedConnection = computed(() =>
|
||||
mcpConnectionSettings.value.connections.find((connection) => connection.id === draft.value.id)
|
||||
)
|
||||
|
||||
function current(request: number): boolean {
|
||||
return !disposed && request === version
|
||||
}
|
||||
|
||||
function startAdd(): void {
|
||||
version++
|
||||
draft.value = createMCPConnectionDraft()
|
||||
|
|
@ -46,29 +54,39 @@ export function useMCPConnectionSettings(
|
|||
tokenStatus.value = 'missing'
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function startEdit(id: string): Promise<boolean> {
|
||||
const connection = mcpConnectionSettings.value.connections.find((item) => item.id === id)
|
||||
if (!connection) return false
|
||||
|
||||
const request = ++version
|
||||
draft.value = createMCPConnectionDraft(connection)
|
||||
tokenDraft.value = ''
|
||||
tokenStatus.value = 'missing'
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const status = await services.status(connection.id)
|
||||
if (!current(request)) return false
|
||||
|
||||
tokenStatus.value = status
|
||||
return true
|
||||
} catch (cause) {
|
||||
if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
return false
|
||||
if (!current(request)) return false
|
||||
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
const request = ++version
|
||||
const target = { ...draft.value }
|
||||
const id: MCPConnectionID = draft.value.id ?? `mcp-${crypto.randomUUID()}`
|
||||
draft.value.id = id
|
||||
const target = { ...draft.value, id }
|
||||
const token = tokenDraft.value
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
if (
|
||||
target.enabled &&
|
||||
|
|
@ -78,10 +96,19 @@ export function useMCPConnectionSettings(
|
|||
) {
|
||||
throw new Error(automation.value.bearerTokenRequired)
|
||||
}
|
||||
const connection = services.save(target)
|
||||
if (target.authenticationType === 'none') await services.setCredential(connection.id, '')
|
||||
else if (token.trim()) await services.setCredential(connection.id, token)
|
||||
|
||||
await enqueueMCPConnectionMutation(id, async () => {
|
||||
const connection = services.save({ ...target, enabled: false })
|
||||
if (target.authenticationType === 'none') await services.setCredential(connection.id, '')
|
||||
else if (token.trim()) await services.setCredential(connection.id, token)
|
||||
else if (target.enabled && (await services.status(connection.id)) !== 'configured') {
|
||||
throw new Error(automation.value.bearerTokenRequired)
|
||||
}
|
||||
|
||||
services.save({ ...target, id: connection.id })
|
||||
})
|
||||
if (!current(request)) return false
|
||||
|
||||
if (tokenDraft.value === token) tokenDraft.value = ''
|
||||
return true
|
||||
} catch (cause) {
|
||||
|
|
@ -89,17 +116,25 @@ export function useMCPConnectionSettings(
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCredential(): Promise<void> {
|
||||
const target = { ...draft.value }
|
||||
if (!target.id) return
|
||||
const id = draft.value.id
|
||||
if (!id) return
|
||||
|
||||
const request = ++version
|
||||
const token = tokenDraft.value
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await services.setCredential(target.id, '')
|
||||
// Disable the connection whose credential was cleared, even if the editor moved on.
|
||||
services.save({ ...target, enabled: false })
|
||||
await enqueueMCPConnectionMutation(id, async () => {
|
||||
const connection = mcpConnectionSettings.value.connections.find((item) => item.id === id)
|
||||
if (!connection) throw new Error('Connection no longer exists')
|
||||
|
||||
services.save({ ...createMCPConnectionDraft(connection), enabled: false })
|
||||
await services.setCredential(id, '')
|
||||
})
|
||||
if (!current(request)) return
|
||||
|
||||
draft.value.enabled = false
|
||||
if (tokenDraft.value === token) tokenDraft.value = ''
|
||||
tokenStatus.value = 'missing'
|
||||
|
|
@ -107,25 +142,30 @@ export function useMCPConnectionSettings(
|
|||
if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(): Promise<boolean> {
|
||||
const id = draft.value.id
|
||||
if (!id) return false
|
||||
|
||||
const request = ++version
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await services.remove(id)
|
||||
await enqueueMCPConnectionMutation(id, () => services.remove(id))
|
||||
return current(request)
|
||||
} catch (cause) {
|
||||
if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => draft.value.authenticationType,
|
||||
(type) => {
|
||||
if (type === 'none') tokenDraft.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
draft,
|
||||
tokenStatus,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { tryOnScopeDispose } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch, type Ref } from 'vue'
|
||||
|
||||
import {
|
||||
|
|
@ -13,13 +14,16 @@ import { appCredentialServices } from '@/app/settings/credentials/app'
|
|||
import { credentialRef } from '@/app/settings/credentials/reference'
|
||||
import type { CredentialStatus } from '@/app/settings/credentials/types'
|
||||
import { resumeStorageSync } from '@/app/storage/sync'
|
||||
|
||||
export function useStorageSettings(credentialDrafts: Ref<Record<string, string>>) {
|
||||
const provider = computed(() => storageProviderRegistry.get(activeStorageProviderID.value))
|
||||
|
||||
const preferenceDrafts = ref<Record<string, string>>({
|
||||
...readStoragePreferences(provider.value.id)
|
||||
})
|
||||
const credentialStatuses = ref<Record<string, CredentialStatus>>({})
|
||||
const busy = ref(false)
|
||||
|
||||
const configured = computed(
|
||||
() =>
|
||||
storagePreferencesComplete(provider.value.id) &&
|
||||
|
|
@ -27,55 +31,84 @@ export function useStorageSettings(credentialDrafts: Ref<Record<string, string>>
|
|||
(field) => !field.required || credentialStatuses.value[field.id] === 'configured'
|
||||
)
|
||||
)
|
||||
|
||||
let statusRequest = 0
|
||||
let disposed = false
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
disposed = true
|
||||
statusRequest++
|
||||
})
|
||||
|
||||
async function refreshStatuses(): Promise<void> {
|
||||
const id = provider.value.id
|
||||
const statuses = await storageCredentialStatuses(id)
|
||||
if (provider.value.id === id) credentialStatuses.value = statuses
|
||||
const request = ++statusRequest
|
||||
const target = provider.value
|
||||
try {
|
||||
const statuses = await storageCredentialStatuses(target.id)
|
||||
if (!disposed && statusRequest === request && provider.value.id === target.id)
|
||||
credentialStatuses.value = statuses
|
||||
} catch {
|
||||
if (!disposed && statusRequest === request && provider.value.id === target.id) {
|
||||
credentialStatuses.value = Object.fromEntries(
|
||||
target.credentialFields.map((field) => [field.id, 'unavailable' as const])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function savePreferences(): void {
|
||||
for (const field of provider.value.preferenceFields) {
|
||||
writeStoragePreference(provider.value.id, field.id, preferenceDrafts.value[field.id] ?? '')
|
||||
}
|
||||
void resumeStorageSync()
|
||||
}
|
||||
|
||||
async function saveCredential(field: string): Promise<void> {
|
||||
const target = credentialDrafts.value
|
||||
const value = target[field]?.trim()
|
||||
if (!value) return
|
||||
|
||||
await appCredentialServices.manager.set(credentialRef(provider.value.id, field), value)
|
||||
if (credentialDrafts.value === target && target[field]?.trim() === value) target[field] = ''
|
||||
await refreshStatuses()
|
||||
await resumeStorageSync()
|
||||
}
|
||||
|
||||
async function clearCredential(field: string): Promise<void> {
|
||||
const target = credentialDrafts.value
|
||||
await appCredentialServices.manager.clear(credentialRef(provider.value.id, field))
|
||||
if (credentialDrafts.value === target) target[field] = ''
|
||||
await refreshStatuses()
|
||||
}
|
||||
|
||||
watch(activeStorageProviderID, (providerID) => {
|
||||
credentialStatuses.value = {}
|
||||
preferenceDrafts.value = { ...readStoragePreferences(providerID) }
|
||||
credentialDrafts.value = {}
|
||||
void refreshStatuses()
|
||||
})
|
||||
|
||||
onMounted(() => void refreshStatuses())
|
||||
|
||||
async function testConnection() {
|
||||
busy.value = true
|
||||
const target = provider.value
|
||||
|
||||
try {
|
||||
savePreferences()
|
||||
for (const field of target.credentialFields) {
|
||||
if (provider.value.id !== target.id) return null
|
||||
|
||||
await saveCredential(field.id)
|
||||
}
|
||||
await resumeStorageSync()
|
||||
if (provider.value.id !== target.id) return null
|
||||
|
||||
const result = await createActiveStorageAdapter(target.id).testConnection()
|
||||
return provider.value.id === target.id ? result : null
|
||||
} catch (error) {
|
||||
if (provider.value.id !== target.id) return null
|
||||
|
||||
return { ok: false as const, message: error instanceof Error ? error.message : String(error) }
|
||||
} finally {
|
||||
busy.value = false
|
||||
|
|
|
|||
|
|
@ -4,19 +4,25 @@ import { onMounted, ref } from 'vue'
|
|||
import { diagnostics } from '@/app/diagnostics'
|
||||
import { isUsageEnabled } from '@/app/diagnostics/settings'
|
||||
import { summarizeUsage, type UsageSummary } from '@/app/usage'
|
||||
|
||||
export function useUsageSettings() {
|
||||
let version = 0
|
||||
let disposed = false
|
||||
|
||||
const summary = ref<UsageSummary>(summarizeUsage([]))
|
||||
|
||||
async function refresh() {
|
||||
const request = ++version
|
||||
const events = await diagnostics.list()
|
||||
if (!disposed && request === version && isUsageEnabled()) summary.value = summarizeUsage(events)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isUsageEnabled()) return
|
||||
|
||||
void refresh()
|
||||
})
|
||||
|
||||
const unsubscribe = diagnostics.subscribe(() => {
|
||||
if (isUsageEnabled()) void refresh()
|
||||
else {
|
||||
|
|
@ -24,6 +30,7 @@ export function useUsageSettings() {
|
|||
summary.value = summarizeUsage([])
|
||||
}
|
||||
})
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
disposed = true
|
||||
unsubscribe()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { expect, test } from 'bun:test'
|
||||
import { beforeEach, afterEach, expect, test } from 'bun:test'
|
||||
|
||||
import { effectScope, ref } from 'vue'
|
||||
|
||||
|
|
@ -13,6 +13,15 @@ const connection: MCPConnection = {
|
|||
authentication: { type: 'none' }
|
||||
}
|
||||
|
||||
let previousConnections = mcpConnectionSettings.value.connections
|
||||
beforeEach(() => {
|
||||
previousConnections = mcpConnectionSettings.value.connections
|
||||
mcpConnectionSettings.value.connections = [structuredClone(connection)]
|
||||
})
|
||||
afterEach(() => {
|
||||
mcpConnectionSettings.value.connections = previousConnections
|
||||
})
|
||||
|
||||
test('MCP credential clear disables its captured target without changing a new draft', async () => {
|
||||
const scope = effectScope()
|
||||
let finish: () => void = () => undefined
|
||||
|
|
@ -106,3 +115,206 @@ test('MCP save and clear failures preserve token input', async () => {
|
|||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('failed bearer write never enables the saved connection', async () => {
|
||||
const scope = effectScope()
|
||||
const saved: boolean[] = []
|
||||
try {
|
||||
const state = scope.run(() =>
|
||||
useMCPConnectionSettings(ref('token'), ref({ bearerTokenRequired: 'Required' }), {
|
||||
status: async () => 'configured',
|
||||
setCredential: async () => {
|
||||
throw new Error('Write failed')
|
||||
},
|
||||
save: (draft) => {
|
||||
saved.push(draft.enabled)
|
||||
return connection
|
||||
},
|
||||
remove: async () => undefined
|
||||
})
|
||||
)
|
||||
if (!state) throw new Error('Missing scope')
|
||||
state.draft.value.authenticationType = 'bearer'
|
||||
state.draft.value.enabled = true
|
||||
expect(await state.save()).toBe(false)
|
||||
expect(saved).toEqual([false])
|
||||
} finally {
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('clear uses persisted fields rather than invalid unsaved input', async () => {
|
||||
const scope = effectScope()
|
||||
const names: string[] = []
|
||||
try {
|
||||
const state = scope.run(() =>
|
||||
useMCPConnectionSettings(ref(''), ref({ bearerTokenRequired: 'Required' }), {
|
||||
status: async () => 'configured',
|
||||
setCredential: async () => undefined,
|
||||
save: (draft) => {
|
||||
names.push(draft.name)
|
||||
expect(draft.url).toBe(connection.transport.url)
|
||||
return connection
|
||||
},
|
||||
remove: async () => undefined
|
||||
})
|
||||
)
|
||||
if (!state) throw new Error('Missing scope')
|
||||
state.draft.value.id = connection.id
|
||||
state.draft.value.name = ''
|
||||
state.draft.value.url = 'invalid'
|
||||
await state.clearCredential()
|
||||
expect(names).toEqual(['Test'])
|
||||
} finally {
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('status lookup failure opens the editor with an error', async () => {
|
||||
const scope = effectScope()
|
||||
try {
|
||||
const state = scope.run(() =>
|
||||
useMCPConnectionSettings(ref(''), ref({ bearerTokenRequired: 'Required' }), {
|
||||
status: async () => {
|
||||
throw new Error('Locked')
|
||||
},
|
||||
setCredential: async () => undefined,
|
||||
save: () => connection,
|
||||
remove: async () => undefined
|
||||
})
|
||||
)
|
||||
if (!state) throw new Error('Missing scope')
|
||||
expect(await state.startEdit(connection.id)).toBe(true)
|
||||
expect(state.error.value).toBe('Locked')
|
||||
} finally {
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('serializes overlapping credential replacements', async () => {
|
||||
const scope = effectScope()
|
||||
const writes: string[] = []
|
||||
let finish: () => void = () => undefined
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const token = ref('first')
|
||||
try {
|
||||
const state = scope.run(() =>
|
||||
useMCPConnectionSettings(token, ref({ bearerTokenRequired: 'Required' }), {
|
||||
status: async () => 'configured',
|
||||
setCredential: async (_id, value) => {
|
||||
writes.push(value)
|
||||
if (value === 'first') await blocked
|
||||
},
|
||||
save: () => connection,
|
||||
remove: async () => undefined
|
||||
})
|
||||
)
|
||||
if (!state) throw new Error('Missing scope')
|
||||
state.draft.value.id = connection.id
|
||||
state.draft.value.authenticationType = 'bearer'
|
||||
const first = state.save()
|
||||
await Promise.resolve()
|
||||
token.value = 'second'
|
||||
const second = state.save()
|
||||
await Promise.resolve()
|
||||
expect(writes).toEqual(['first'])
|
||||
finish()
|
||||
await Promise.all([first, second])
|
||||
expect(writes).toEqual(['first', 'second'])
|
||||
} finally {
|
||||
finish()
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('reopened editors serialize writes to the same connection while other connections proceed', async () => {
|
||||
const firstScope = effectScope()
|
||||
const secondScope = effectScope()
|
||||
let finish: () => void = () => undefined
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const writes: string[] = []
|
||||
const services = {
|
||||
status: async () => 'configured' as const,
|
||||
setCredential: async (_id: string, value: string) => {
|
||||
writes.push(value)
|
||||
if (value === 'first') await blocked
|
||||
},
|
||||
save: (draft: { id: `mcp-${string}` | null }) => ({
|
||||
...connection,
|
||||
id: draft.id ?? connection.id
|
||||
}),
|
||||
remove: async () => undefined
|
||||
}
|
||||
try {
|
||||
const first = firstScope.run(() =>
|
||||
useMCPConnectionSettings(ref('first'), ref({ bearerTokenRequired: 'Required' }), services)
|
||||
)
|
||||
const second = secondScope.run(() =>
|
||||
useMCPConnectionSettings(ref('second'), ref({ bearerTokenRequired: 'Required' }), services)
|
||||
)
|
||||
const other = secondScope.run(() =>
|
||||
useMCPConnectionSettings(ref('other'), ref({ bearerTokenRequired: 'Required' }), services)
|
||||
)
|
||||
if (!first || !second || !other) throw new Error('Missing scope')
|
||||
for (const editor of [first, second, other]) {
|
||||
editor.draft.value.id = connection.id
|
||||
editor.draft.value.authenticationType = 'bearer'
|
||||
}
|
||||
other.draft.value.id = 'mcp-other'
|
||||
const savingFirst = first.save()
|
||||
await Promise.resolve()
|
||||
firstScope.stop()
|
||||
const savingSecond = second.save()
|
||||
await other.save()
|
||||
expect(writes).toEqual(['first', 'other'])
|
||||
finish()
|
||||
await Promise.all([savingFirst, savingSecond])
|
||||
expect(writes).toEqual(['first', 'other', 'second'])
|
||||
} finally {
|
||||
finish()
|
||||
firstScope.stop()
|
||||
secondScope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('blank-token save rechecks status after a different editor clears the credential', async () => {
|
||||
const scope = effectScope()
|
||||
let configured = true
|
||||
const enabled: boolean[] = []
|
||||
const services = {
|
||||
status: async () => (configured ? ('configured' as const) : ('missing' as const)),
|
||||
setCredential: async (_id: string, value: string) => {
|
||||
configured = Boolean(value)
|
||||
},
|
||||
save: (draft: { enabled: boolean }) => {
|
||||
enabled.push(draft.enabled)
|
||||
return connection
|
||||
},
|
||||
remove: async () => undefined
|
||||
}
|
||||
try {
|
||||
const first = scope.run(() =>
|
||||
useMCPConnectionSettings(ref(''), ref({ bearerTokenRequired: 'Required' }), services)
|
||||
)
|
||||
const second = scope.run(() =>
|
||||
useMCPConnectionSettings(ref(''), ref({ bearerTokenRequired: 'Required' }), services)
|
||||
)
|
||||
if (!first || !second) throw new Error('Missing scope')
|
||||
await first.startEdit(connection.id)
|
||||
await second.startEdit(connection.id)
|
||||
second.draft.value.authenticationType = 'bearer'
|
||||
second.draft.value.enabled = true
|
||||
const clearing = first.clearCredential()
|
||||
const saving = second.save()
|
||||
await clearing
|
||||
expect(await saving).toBe(false)
|
||||
expect(second.error.value).toBe('Required')
|
||||
expect(enabled).toEqual([false, false])
|
||||
} finally {
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue