diff --git a/src/app/ai/agents/discovery.ts b/src/app/ai/agents/discovery.ts index 81d30b554..ee6173a54 100644 --- a/src/app/ai/agents/discovery.ts +++ b/src/app/ai/agents/discovery.ts @@ -46,6 +46,15 @@ export function detectedAgents(lookup: AgentLookup): DetectedAgent[] { }) } +/** What went wrong in the last lookup or installation. */ +export type DiscoveryError = + | 'lookup' + | 'install' + | 'npm' + | 'canvas-install' + | 'canvas-start' + | 'harness-install' + export function createAgentDiscovery(options: { enabled: boolean lookup: () => Promise @@ -57,15 +66,15 @@ export function createAgentDiscovery(options: { const snapshot = shallowRef(null) const scanning = ref(false) const installing = ref(null) - const error = ref< - 'lookup' | 'install' | 'npm' | 'canvas-install' | 'canvas-start' | 'harness-install' | null - >(null) + const error = ref(null) let pending: Promise | null = null const agents = computed(() => (snapshot.value ? detectedAgents(snapshot.value) : [])) const availableAgents = computed(() => agents.value.filter((agent) => agent.status === 'available') ) const npmAvailable = computed(() => Boolean(snapshot.value?.executables.npm)) + /** A lookup has finished, so a later rescan can keep showing its results. */ + const checked = computed(() => snapshot.value !== null) // Checks read through functions so they see the snapshot of a rescan that just finished. function hasBridge(): boolean { return Boolean(snapshot.value?.executables[MCP_EXECUTABLE]) @@ -191,6 +200,7 @@ export function createAgentDiscovery(options: { agents, availableAgents, scanning, + checked, installing, error, npmAvailable, diff --git a/src/app/ai/agents/setup.ts b/src/app/ai/agents/setup.ts new file mode 100644 index 000000000..b3c222cc1 --- /dev/null +++ b/src/app/ai/agents/setup.ts @@ -0,0 +1,269 @@ +import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue' + +import type { ACPAgentDef } from '@open-pencil/core/constants' + +import { MCP_INSTALL_COMMAND } from '@/app/ai/acp/setup-prompt' +import { readPiAccount } from '@/app/ai/harness/pi-settings' + +import { agentDiscovery, type DetectedAgent, type DiscoveryError } from './discovery' + +type AgentDiscovery = typeof agentDiscovery + +/** What setup knows about one coding agent and the MCP server it needs. */ +export interface AgentSetupState { + /** False where agents cannot run, so only manual instructions apply. */ + supported: boolean + /** A check has finished, so a later one keeps showing its results. */ + checked: boolean + scanning: boolean + detected: DetectedAgent | null + /** OpenPencil's MCP server, through which agents reach the canvas. */ + bridge: boolean + /** The installed MCP server does not match this app's version. */ + bridgeOutdated: boolean + /** Installs or updates the MCP server by hand. */ + bridgeCommand: string + /** npm, which one-click installation runs. */ + npm: boolean + installingAgent: boolean + installingBridge: boolean + error: DiscoveryError | null +} + +/** What setup knows about Pi: the Harness companion that runs it and Pi's own settings. */ +export interface PiSetupState { + supported: boolean + checked: boolean + scanning: boolean + /** The `openpencil-harness` companion that runs Pi. */ + companion: boolean + companionOutdated: boolean + companionCommand: string + bridge: boolean + bridgeOutdated: boolean + bridgeCommand: string + npm: boolean + installingCompanion: boolean + installingBridge: boolean + /** The default model set in Pi, used unless OpenPencil names another. */ + defaultModel: string | null + error: DiscoveryError | null +} + +/** An OpenPencil companion as setup presents it, with the one-click fix that applies. */ +export interface CompanionCheck { + /** Installed and matching this app. */ + ready: boolean + outdated: boolean + installing: boolean + /** Absent once ready, and without npm, which one-click installation runs. */ + action: 'install' | 'update' | null +} + +/** Why setup cannot finish on its own, in the order the person should address it. */ +export type SetupProblem = 'needs-npm' | 'install-failed' | 'mcp-start-failed' | 'lookup-failed' + +function companionCheck( + installed: boolean, + outdated: boolean, + installing: boolean, + npm: boolean +): CompanionCheck { + const ready = installed && !outdated + if (ready || !npm) return { ready, outdated, installing, action: null } + return { ready, outdated, installing, action: outdated ? 'update' : 'install' } +} + +function setupProblem( + error: DiscoveryError | null, + needsNpm: boolean, + installErrors: readonly DiscoveryError[] +): SetupProblem | null { + if (error === 'npm' || needsNpm) return 'needs-npm' + if (error !== null && installErrors.includes(error)) return 'install-failed' + if (error === 'canvas-start') return 'mcp-start-failed' + if (error === 'lookup') return 'lookup-failed' + return null +} + +/** A coding agent's setup: its CLI and adapter, the MCP server, and what to run by hand. */ +export interface AgentSetupView { + bridge: CompanionCheck + /** The adapter can be installed with one click. */ + canInstallAdapter: boolean + busy: boolean + problem: SetupProblem | null + /** Commands for where one-click installation cannot help. */ + manualAgentCommand: string | null + manualBridgeCommand: string | null +} + +export function agentSetupView(agent: ACPAgentDef, setup: AgentSetupState): AgentSetupView { + const bridge = companionCheck( + setup.bridge, + setup.bridgeOutdated, + setup.installingBridge, + setup.npm + ) + const status = setup.detected?.status + const needsNpm = + !setup.npm && (status === 'needs-adapter' || (setup.detected !== null && !bridge.ready)) + let manualAgentCommand: string | null = null + if (agent.installCommand && status !== 'available') { + const oneClick = setup.supported && setup.npm && setup.error !== 'install' + manualAgentCommand = oneClick && agent.adapterPackage ? null : agent.installCommand + } + let manualBridgeCommand: string | null = null + if (!setup.supported) manualBridgeCommand = MCP_INSTALL_COMMAND + else if (!bridge.ready && (!setup.npm || setup.error === 'canvas-install')) { + manualBridgeCommand = setup.bridgeCommand + } + return { + bridge, + canInstallAdapter: status === 'needs-adapter' && setup.npm, + busy: setup.installingAgent || setup.installingBridge, + problem: setupProblem(setup.error, needsNpm, ['install', 'canvas-install']), + manualAgentCommand, + manualBridgeCommand + } +} + +/** Pi's setup: the Harness companion, the MCP server, and what to run by hand. */ +export interface PiSetupView { + companion: CompanionCheck + bridge: CompanionCheck + busy: boolean + problem: SetupProblem | null + manualCommands: string[] +} + +export function piSetupView(setup: PiSetupState): PiSetupView { + const companion = companionCheck( + setup.companion, + setup.companionOutdated, + setup.installingCompanion, + setup.npm + ) + const bridge = companionCheck( + setup.bridge, + setup.bridgeOutdated, + setup.installingBridge, + setup.npm + ) + const missing = !companion.ready || !bridge.ready + const manual = !setup.npm || setup.error === 'harness-install' || setup.error === 'canvas-install' + return { + companion, + bridge, + busy: setup.installingCompanion || setup.installingBridge, + problem: setupProblem(setup.error, setup.checked && !setup.npm && missing, [ + 'harness-install', + 'canvas-install' + ]), + manualCommands: manual + ? [ + ...(companion.ready ? [] : [setup.companionCommand]), + ...(bridge.ready ? [] : [setup.bridgeCommand]) + ] + : [] + } +} + +/** Setup state of the coding agents and Pi, for guided setup and model settings. */ +export function useAgentSetup( + discovery: AgentDiscovery = agentDiscovery, + readPi: typeof readPiAccount = readPiAccount +) { + const piDefaultModel = ref(null) + const readingPi = ref(false) + + function agentID(providerID: string) { + return providerID.startsWith('acp:') ? providerID.slice('acp:'.length) : null + } + + function agentSetup(providerID: string): AgentSetupState { + const id = agentID(providerID) + return { + supported: discovery.supported, + checked: discovery.checked.value, + scanning: discovery.scanning.value, + detected: discovery.agents.value.find((agent) => agent.definition.id === id) ?? null, + bridge: discovery.canvasBridgeAvailable.value, + bridgeOutdated: discovery.canvasBridgeOutdated.value, + bridgeCommand: discovery.canvasBridgeCommand.value, + npm: discovery.npmAvailable.value, + installingAgent: discovery.installing.value === id, + installingBridge: discovery.installing.value === 'canvas', + error: discovery.error.value + } + } + + function piSetup(providerID: string): PiSetupState | undefined { + if (providerID !== 'harness:pi') return undefined + return { + supported: discovery.supported, + checked: discovery.checked.value, + scanning: discovery.scanning.value || readingPi.value, + companion: discovery.harnessAvailable.value, + companionOutdated: discovery.harnessOutdated.value, + companionCommand: discovery.harnessCommand.value, + bridge: discovery.canvasBridgeAvailable.value, + bridgeOutdated: discovery.canvasBridgeOutdated.value, + bridgeCommand: discovery.canvasBridgeCommand.value, + npm: discovery.npmAvailable.value, + installingCompanion: discovery.installing.value === 'harness', + installingBridge: discovery.installing.value === 'canvas', + defaultModel: piDefaultModel.value, + error: discovery.error.value + } + } + + function installAgent(providerID: string): Promise { + if (providerID === 'harness:pi') return discovery.setupHarness() + const detected = agentSetup(providerID).detected + return detected ? discovery.install(detected.definition.id) : Promise.resolve() + } + + async function refreshAgents(): Promise { + if (!discovery.supported) return + readingPi.value = true + try { + const [, account] = await Promise.all([discovery.refresh(true), readPi().catch(() => null)]) + piDefaultModel.value = account?.defaultModel ?? null + } finally { + readingPi.value = false + } + } + + return { + agentSetup, + piSetup, + installAgent, + refreshAgents, + setupCanvasBridge: () => discovery.setupCanvasBridge() + } +} + +/** + * Pi's setup while `active`, checked each time it becomes active. Undefined outside the desktop + * app, where Pi cannot run and there is nothing to check. + */ +export function usePiSetup(active: MaybeRefOrGetter, setup = useAgentSetup()) { + const state = computed(() => { + const pi = toValue(active) ? setup.piSetup('harness:pi') : undefined + return pi?.supported ? pi : undefined + }) + watch( + () => toValue(active), + (on) => { + if (on) void setup.refreshAgents() + }, + { immediate: true } + ) + return { + state, + refresh: setup.refreshAgents, + installCompanion: () => setup.installAgent('harness:pi'), + installBridge: setup.setupCanvasBridge + } +} diff --git a/src/app/ai/models/settings/onboarding/agents.ts b/src/app/ai/models/settings/onboarding/agents.ts deleted file mode 100644 index df63921d5..000000000 --- a/src/app/ai/models/settings/onboarding/agents.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { ref } from 'vue' - -import { agentDiscovery, type DetectedAgent } from '@/app/ai/agents/discovery' -import { readPiAccount } from '@/app/ai/harness/pi-settings' - -import type { OnboardingAccess } from './plan' - -type AgentDiscovery = typeof agentDiscovery - -/** What guided setup knows about one coding agent and the MCP server it needs. */ -export interface AgentSetupState { - /** False where agents cannot run, so only manual instructions apply. */ - supported: boolean - scanning: boolean - detected: DetectedAgent | null - /** OpenPencil's MCP server, through which agents reach the canvas. */ - bridge: boolean - /** The installed MCP server does not match this app's version. */ - bridgeOutdated: boolean - /** Installs or updates the MCP server by hand. */ - bridgeCommand: string - /** npm, which one-click installation runs. */ - npm: boolean - installingAgent: boolean - installingBridge: boolean - error: AgentDiscovery['error']['value'] -} - -/** What guided setup knows about Pi: the Harness companion that runs it and Pi's own settings. */ -export interface PiSetupState { - supported: boolean - scanning: boolean - /** The `openpencil-harness` companion that runs Pi. */ - companion: boolean - companionOutdated: boolean - companionCommand: string - bridge: boolean - bridgeOutdated: boolean - bridgeCommand: string - npm: boolean - installingCompanion: boolean - installingBridge: boolean - /** The default model set in Pi, used unless OpenPencil names another. */ - defaultModel: string | null - error: AgentDiscovery['error']['value'] -} - -/** Coding agent setup in guided setup, backed by the desktop agent discovery. */ -export function useOnboardingAgents( - discovery: AgentDiscovery = agentDiscovery, - readPi: typeof readPiAccount = readPiAccount -) { - const piDefaultModel = ref(null) - const readingPi = ref(false) - - function agentID(providerID: OnboardingAccess) { - return providerID.startsWith('acp:') ? providerID.slice('acp:'.length) : null - } - - function agentSetup(providerID: OnboardingAccess): AgentSetupState { - const id = agentID(providerID) - return { - supported: discovery.supported, - scanning: discovery.scanning.value, - detected: discovery.agents.value.find((agent) => agent.definition.id === id) ?? null, - bridge: discovery.canvasBridgeAvailable.value, - bridgeOutdated: discovery.canvasBridgeOutdated.value, - bridgeCommand: discovery.canvasBridgeCommand.value, - npm: discovery.npmAvailable.value, - installingAgent: discovery.installing.value === id, - installingBridge: discovery.installing.value === 'canvas', - error: discovery.error.value - } - } - - function piSetup(providerID: OnboardingAccess): PiSetupState | undefined { - if (providerID !== 'harness:pi') return undefined - return { - supported: discovery.supported, - scanning: discovery.scanning.value || readingPi.value, - companion: discovery.harnessAvailable.value, - companionOutdated: discovery.harnessOutdated.value, - companionCommand: discovery.harnessCommand.value, - bridge: discovery.canvasBridgeAvailable.value, - bridgeOutdated: discovery.canvasBridgeOutdated.value, - bridgeCommand: discovery.canvasBridgeCommand.value, - npm: discovery.npmAvailable.value, - installingCompanion: discovery.installing.value === 'harness', - installingBridge: discovery.installing.value === 'canvas', - defaultModel: piDefaultModel.value, - error: discovery.error.value - } - } - - function installAgent(providerID: OnboardingAccess): Promise { - if (providerID === 'harness:pi') return discovery.setupHarness() - const detected = agentSetup(providerID).detected - return detected ? discovery.install(detected.definition.id) : Promise.resolve() - } - - async function refreshAgents(): Promise { - if (!discovery.supported) return - readingPi.value = true - try { - const [, account] = await Promise.all([discovery.refresh(true), readPi().catch(() => null)]) - piDefaultModel.value = account?.defaultModel ?? null - } finally { - readingPi.value = false - } - } - - return { - agentSetup, - piSetup, - installAgent, - refreshAgents, - setupCanvasBridge: () => discovery.setupCanvasBridge() - } -} diff --git a/src/app/ai/models/settings/onboarding/connections.ts b/src/app/ai/models/settings/onboarding/connections.ts index 5dba90316..29edc2498 100644 --- a/src/app/ai/models/settings/onboarding/connections.ts +++ b/src/app/ai/models/settings/onboarding/connections.ts @@ -40,6 +40,15 @@ export const ONBOARDING_SERVER_PRESETS = [ { id: 'lmstudio', name: 'LM Studio', baseURL: 'http://localhost:1234/v1' } ] as const +export type OnboardingServerPreset = (typeof ONBOARDING_SERVER_PRESETS)[number]['id'] | 'custom' + +/** The preset whose address the person entered, or `custom` for any other server. */ +export function serverPresetFor(baseURL: string): OnboardingServerPreset { + return ( + ONBOARDING_SERVER_PRESETS.find((preset) => preset.baseURL === baseURL.trim())?.id ?? 'custom' + ) +} + /** Details the person edits while connecting. */ export type OnboardingConnectionPatch = Partial< Pick @@ -147,6 +156,12 @@ export function useOnboardingConnections({ plannedModel }: OnboardingConnections state.reason = null } + /** Applies what the person typed; a changed connection needs a new test. */ + function updateConnection(providerID: OnboardingAccess, patch: OnboardingConnectionPatch): void { + Object.assign(connection(providerID), patch) + resetTest(providerID) + } + async function testConnection(providerID: OnboardingAccess): Promise { const request = (testVersions.get(providerID) ?? 0) + 1 testVersions.set(providerID, request) @@ -182,5 +197,14 @@ export function useOnboardingConnections({ plannedModel }: OnboardingConnections keyStatuses[connectionId] = 'configured' } - return { connection, hasSavedKey, ready, resetTest, testConnection, clearKeys, markKeySaved } + return { + connection, + hasSavedKey, + ready, + resetTest, + updateConnection, + testConnection, + clearKeys, + markKeySaved + } } diff --git a/src/app/ai/models/settings/onboarding/plan.ts b/src/app/ai/models/settings/onboarding/plan.ts index 5040f0eca..411a6f63a 100644 --- a/src/app/ai/models/settings/onboarding/plan.ts +++ b/src/app/ai/models/settings/onboarding/plan.ts @@ -64,14 +64,6 @@ export interface PlannedModel { /** A model for a role, the design model (`'design'`), or nothing. */ export type PlannedRole = PlannedModel | 'design' | null -/** Roles in the order setup shows them. */ -export const ONBOARDING_ROLES = [ - 'design', - 'vision', - 'review', - 'fast' -] as const satisfies readonly AIModelRole[] - export interface OnboardingPlan { /** `null` keeps the design model that is configured now. */ design: PlannedModel | null diff --git a/src/app/ai/models/settings/onboarding/roles.ts b/src/app/ai/models/settings/onboarding/roles.ts index b3cdf3803..5aa257295 100644 --- a/src/app/ai/models/settings/onboarding/roles.ts +++ b/src/app/ai/models/settings/onboarding/roles.ts @@ -1,9 +1,8 @@ import { computed, ref, type ComputedRef } from 'vue' -import type { AIModelRole } from '@/app/ai/models/types' +import { AI_MODEL_ROLES, type AIModelRole } from '@/app/ai/models/types' import { - ONBOARDING_ROLES, planConnections, roleChoiceKey, roleOptions, @@ -45,7 +44,7 @@ export function useOnboardingRoles( }) const isRecommended = computed(() => - ONBOARDING_ROLES.every( + AI_MODEL_ROLES.every( (role) => roleChoiceKey(plan.value[role]) === roleChoiceKey(recommended.value[role]) ) ) diff --git a/src/app/ai/models/settings/onboarding/use.ts b/src/app/ai/models/settings/onboarding/use.ts index 0c2f6ce90..4eb7f8beb 100644 --- a/src/app/ai/models/settings/onboarding/use.ts +++ b/src/app/ai/models/settings/onboarding/use.ts @@ -2,6 +2,7 @@ import { computed, reactive, ref, watch } from 'vue' import { IS_TAURI } from '@open-pencil/core/constants' +import { useAgentSetup } from '@/app/ai/agents/setup' import { refreshAIProviderStatus } from '@/app/ai/chat/storage' import { aiModelSettings, @@ -11,14 +12,15 @@ import { } from '@/app/ai/models' import type { SettingsSaveResult } from '@/app/settings/save-result' -import { useOnboardingAgents } from './agents' import { applyOnboardingPlan } from './apply' import { existingOnboardingConnection, useOnboardingConnections } from './connections' import { currentOnboardingModels } from './current' import { isOnboardingAccess, + ONBOARDING_GOALS, planOnboarding, type OnboardingAccess, + type OnboardingGoal, type OnboardingAnswers, type PlannedModel, uncoveredGoals @@ -78,7 +80,7 @@ export function useAIOnboarding({ agentsAvailable = IS_TAURI }: AIOnboardingOpti const connections = useOnboardingConnections({ plannedModel }) const { connection, ready, markKeySaved } = connections const signIn = useOnboardingSignIn(connections) - const agents = useOnboardingAgents() + const agents = useAgentSetup() // Look for installed agents when they are about to be connected, and again on return. watch(step, (current) => { if (current === 'connect') void agents.refreshAgents() @@ -92,6 +94,13 @@ export function useAIOnboarding({ agentsAvailable = IS_TAURI }: AIOnboardingOpti return true }) + /** Keeps goals in their canonical order however they were picked. */ + function setGoal(goal: OnboardingGoal, wanted: boolean): void { + answers.goals = ONBOARDING_GOALS.filter((candidate) => + candidate === goal ? wanted : answers.goals.includes(candidate) + ) + } + function next(): void { const index = AI_SETUP_STEPS.indexOf(step.value) if (canContinue.value && index < AI_SETUP_STEPS.length - 1) { @@ -165,6 +174,7 @@ export function useAIOnboarding({ agentsAvailable = IS_TAURI }: AIOnboardingOpti ...connections, ...signIn, ...agents, + setGoal, next, back, apply diff --git a/src/components/settings/agents/AgentSetup.vue b/src/components/settings/agents/AgentSetup.vue new file mode 100644 index 000000000..6513b229c --- /dev/null +++ b/src/components/settings/agents/AgentSetup.vue @@ -0,0 +1,129 @@ + + + diff --git a/src/components/settings/ai-setup/SetupInstallItem.vue b/src/components/settings/agents/AgentSetupItem.vue similarity index 81% rename from src/components/settings/ai-setup/SetupInstallItem.vue rename to src/components/settings/agents/AgentSetupItem.vue index d41d3bd34..57b148e6d 100644 --- a/src/components/settings/ai-setup/SetupInstallItem.vue +++ b/src/components/settings/agents/AgentSetupItem.vue @@ -2,7 +2,7 @@ import { tv } from 'tailwind-variants' import AppButton from '@/components/ui/button/AppButton.vue' -import theme from '@/theme/settings/ai-setup/flow' +import theme from '@/theme/settings/agents' /** One thing guided setup checks on this computer, with an optional one-click fix. */ const { @@ -23,8 +23,8 @@ const styles = tv(theme)()