feat: onboarding offers catalog plugins beside connectors and installs the picked ones before handoff
NS-960. One card, one group ("connectors"): the curated catalog plugins
this OS runs lead the hosted connectors (D1, D4). A plugin whose app is
absent is greyed with the reason and stays pickable (D5). Picks land in
answers.plugins and ride the existing [setup] note to the guide.
The guide's runbook gains the install beat as the last thing before the
handoff card (D2, D3): narrow the picks to what the chosen task needs, then
ONE manage_catalog install call. A new suggested first task sits beside the
existing ones and installs its plugins through that same beat: "Set up my
games and streaming" (NVIDIA App + Broadcast) on a Windows PC with an NVIDIA
GPU, "Help me make something in Blender" everywhere else.
When the guide's install card settles, each plugin row's outcome
(installed / failed / skipped, whatever the user did) is written into the
answers (D6). The build session's runbook names what is ready, what was
offered and not installed, and what was picked but not offered, and tells
the build agent never to install. profiles.remember_onboarding records the
picked plugin names in the default profile's memory, next to the connectors.
This commit is contained in:
committed by
Siddharth Balyan
parent
5c215ffa21
commit
6ec517c475
@@ -27,6 +27,7 @@ import { requestGatewayForAgent } from '@/store/gateway'
|
||||
import { dismissNotification, notify } from '@/store/notifications'
|
||||
import { $onboardingAnswers } from '@/store/onboarding-answers'
|
||||
import { beginOnboardingHandoff, completeOnboardingFlow } from '@/store/onboarding-gate'
|
||||
import { watchPluginOutcomes } from '@/store/onboarding-plugin-outcomes'
|
||||
import { $activeGatewayProfile, $newChatProfile, $newChatRoute, $profiles, ensureGatewayAgent } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
@@ -67,6 +68,10 @@ export function useOnboardingHandoff({
|
||||
const setupHandoff = useStore($setupHandoff)
|
||||
const selectedStoredId = useStore($selectedStoredSessionId)
|
||||
|
||||
// The guide's install card settles before its handoff card; its per-plugin result rides the answers into the
|
||||
// build session's runbook.
|
||||
useEffect(() => watchPluginOutcomes(() => $setupSession.get()?.runtimeId), [])
|
||||
|
||||
// Resume an existing receipt when the welcome chat is reopened after a relaunch. Recovery reads the saved
|
||||
// receipt only; it never creates a new build session.
|
||||
useEffect(() => {
|
||||
@@ -163,7 +168,7 @@ export function useOnboardingHandoff({
|
||||
const result = await request<{ saved?: boolean; profile?: string; target?: string }>(
|
||||
owner,
|
||||
'profiles.remember_onboarding',
|
||||
{ answers: { ...answers, connectors: answers.connectors.map(connectorTitle) } }
|
||||
{ answers: { ...answers, connectors: answers.connectors.map(connectorTitle), plugins: answers.plugins } }
|
||||
)
|
||||
|
||||
if (!result.saved || result.profile !== BUILD_PROFILE || result.target !== 'user') {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
|
||||
import { ConnectorPicks } from '@/components/onboarding-chat/cards/setup'
|
||||
import { $onboardingAnswers, DEFAULT_ANSWERS } from '@/store/onboarding-answers'
|
||||
import type { OnboardingPlugin } from '@/store/onboarding-plugins'
|
||||
|
||||
const plugins: OnboardingPlugin[] = [
|
||||
{
|
||||
app_state: 'unknown',
|
||||
description: '',
|
||||
name: 'blender',
|
||||
platforms: [],
|
||||
sentence: '',
|
||||
tier: 'official',
|
||||
title: 'Blender'
|
||||
},
|
||||
{
|
||||
app_state: 'missing_app',
|
||||
description: '',
|
||||
name: 'nvidia-app',
|
||||
platforms: ['windows'],
|
||||
sentence: 'needs NVIDIA App',
|
||||
tier: 'official',
|
||||
title: 'NVIDIA App'
|
||||
}
|
||||
]
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$onboardingAnswers.set({ ...DEFAULT_ANSWERS, committed: [] })
|
||||
})
|
||||
|
||||
it('lists plugins before connectors; a plugin whose app is missing is greyed with its reason and still picks', () => {
|
||||
const sent: string[] = []
|
||||
|
||||
render(
|
||||
<ConnectorPicks
|
||||
catalog={{ rows: [{ connected: false, connector: 'gmail', enabled: true }], status: 'ready' }}
|
||||
commit={summary => sent.push(summary) > 0}
|
||||
done={false}
|
||||
locked={false}
|
||||
plugins={plugins}
|
||||
/>
|
||||
)
|
||||
|
||||
const labels = screen.getAllByRole('button', { pressed: false }).map(button => button.textContent ?? '')
|
||||
expect(labels.findIndex(text => text.includes('Blender'))).toBeLessThan(labels.findIndex(text => /gmail/i.test(text)))
|
||||
|
||||
const nvidia = screen.getByRole('button', { name: /NVIDIA App/ })
|
||||
expect(nvidia.textContent).toContain('needs NVIDIA App')
|
||||
expect(nvidia.className).toContain('opacity-60')
|
||||
|
||||
fireEvent.click(nvidia)
|
||||
fireEvent.click(screen.getByRole('button', { name: /gmail/i }))
|
||||
expect($onboardingAnswers.get().plugins).toEqual(['nvidia-app'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue with 2' }))
|
||||
expect(sent).toEqual(['apps I use, not connected yet: gmail; plugins picked, not installed yet: nvidia-app'])
|
||||
})
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Puzzle } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
@@ -24,8 +25,10 @@ import { ConnectorLogo } from '@/components/ui/connector-logo'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { connectorIconUrl, connectorTitle } from '@/lib/connector-tools'
|
||||
import { useConnectorCatalog } from '@/store/connector-catalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type ConnectorCatalog, useConnectorCatalog } from '@/store/connector-catalog'
|
||||
import { $onboardingAnswers, setOnboardingAnswers } from '@/store/onboarding-answers'
|
||||
import { type OnboardingPlugin, pluginNeedsApp, useOnboardingPlugins } from '@/store/onboarding-plugins'
|
||||
import { useTheme } from '@/themes'
|
||||
import { setAccentOverride } from '@/themes/accent-override'
|
||||
import { normalizeHex } from '@/themes/color'
|
||||
@@ -34,9 +37,24 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
const view = useSessionView()
|
||||
const storedId = useStore(view.$storedId)
|
||||
const runtimeId = useStore(view.$runtimeId)
|
||||
const answers = useStore($onboardingAnswers)
|
||||
const { commit, done } = useCardCommit('connectors')
|
||||
const catalog = useConnectorCatalog(storedId, runtimeId)
|
||||
const plugins = useOnboardingPlugins(storedId)
|
||||
|
||||
return <ConnectorPicks catalog={catalog} commit={commit} done={done} locked={locked} plugins={plugins} />
|
||||
}
|
||||
|
||||
interface ConnectorPicksProps {
|
||||
catalog: ConnectorCatalog
|
||||
commit: (summary: string) => boolean
|
||||
done: boolean
|
||||
locked: boolean
|
||||
plugins: OnboardingPlugin[]
|
||||
}
|
||||
|
||||
/** The picks themselves, fed by ConnectorsCard. Plugins lead the one group (NS-960 D1). */
|
||||
export function ConnectorPicks({ catalog, commit, done, locked, plugins }: ConnectorPicksProps) {
|
||||
const answers = useStore($onboardingAnswers)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// Only what the gateway carries. A pick is a slug the build chat can hand
|
||||
@@ -47,9 +65,12 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
|
||||
const shown = search
|
||||
? rows.filter(row => connectorTitle(row.connector).toLowerCase().includes(search))
|
||||
: rows.slice(0, 12)
|
||||
: rows.slice(0, Math.max(0, 12 - plugins.length))
|
||||
|
||||
const shownPlugins = search ? plugins.filter(plugin => plugin.title.toLowerCase().includes(search)) : plugins
|
||||
const picked = rows.filter(row => answers.connectors.includes(row.connector))
|
||||
const pickedPlugins = plugins.filter(plugin => answers.plugins.includes(plugin.name))
|
||||
const pickedCount = picked.length + pickedPlugins.length
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setOnboardingAnswers({
|
||||
@@ -58,9 +79,23 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
: [...answers.connectors, id]
|
||||
})
|
||||
|
||||
const togglePlugin = (name: string) =>
|
||||
setOnboardingAnswers({
|
||||
plugins: answers.plugins.includes(name)
|
||||
? answers.plugins.filter(item => item !== name)
|
||||
: [...answers.plugins, name]
|
||||
})
|
||||
|
||||
const summary = () => {
|
||||
const apps = picked.length > 0 ? picked.map(row => row.connector).join(', ') : 'none for now'
|
||||
const tools = pickedPlugins.map(plugin => plugin.name).join(', ')
|
||||
|
||||
return `apps I use, not connected yet: ${apps}${tools ? `; plugins picked, not installed yet: ${tools}` : ''}`
|
||||
}
|
||||
|
||||
// Nothing to pick from: the toolset is off or the gateway is unreachable.
|
||||
// The step still has to end, so the card offers Skip.
|
||||
if (catalog.status === 'unavailable' || (catalog.status === 'ready' && rows.length === 0)) {
|
||||
if (plugins.length === 0 && (catalog.status === 'unavailable' || (catalog.status === 'ready' && rows.length === 0))) {
|
||||
return (
|
||||
<CardFrame
|
||||
continueLabel="Skip this"
|
||||
@@ -77,15 +112,11 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
continueLabel={picked.length > 0 ? `Continue with ${picked.length}` : 'None of these'}
|
||||
continueLabel={pickedCount > 0 ? `Continue with ${pickedCount}` : 'None of these'}
|
||||
disabled={catalog.status === 'loading'}
|
||||
done={done}
|
||||
locked={locked}
|
||||
onContinue={() => {
|
||||
commit(
|
||||
`apps I use, not connected yet: ${picked.length > 0 ? picked.map(row => row.connector).join(', ') : 'none for now'}`
|
||||
)
|
||||
}}
|
||||
onContinue={() => void commit(summary())}
|
||||
>
|
||||
{catalog.status === 'loading' ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
@@ -95,8 +126,25 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{rows.length > 12 ? <SearchField onChange={setQuery} placeholder="Find an app" value={query} /> : null}
|
||||
{rows.length + plugins.length > 12 ? (
|
||||
<SearchField onChange={setQuery} placeholder="Find an app" value={query} />
|
||||
) : null}
|
||||
<div className="grid max-h-72 grid-cols-3 gap-2 overflow-y-auto">
|
||||
{shownPlugins.map(plugin => (
|
||||
<Chip
|
||||
className={cn(pluginNeedsApp(plugin) && 'opacity-60')}
|
||||
icon={
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-background text-muted-foreground">
|
||||
<Puzzle className="size-4" />
|
||||
</span>
|
||||
}
|
||||
key={`plugin:${plugin.name}`}
|
||||
label={plugin.title}
|
||||
on={answers.plugins.includes(plugin.name)}
|
||||
onToggle={() => togglePlugin(plugin.name)}
|
||||
sub={pluginNeedsApp(plugin) ? plugin.sentence : 'Plugin'}
|
||||
/>
|
||||
))}
|
||||
{shown.map(row => (
|
||||
<Chip
|
||||
icon={
|
||||
@@ -122,8 +170,8 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
here. Saying so is what keeps the Connect cards later from reading as
|
||||
a second ask for the same thing. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong className="font-medium text-foreground">Nothing connects yet.</strong> Hermes will offer to link these
|
||||
when a task needs them, and asks before reading anything.
|
||||
<strong className="font-medium text-foreground">Nothing connects or installs yet.</strong> Hermes will offer to
|
||||
link these, or install a plugin, when a task needs them, and asks first.
|
||||
</p>
|
||||
</CardFrame>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildFirstTaskRunbook, pluginsRunbook } from '@/components/onboarding-chat/setup-profile'
|
||||
import { DEFAULT_ANSWERS } from '@/store/onboarding-answers'
|
||||
import { buildChatOnboardingPrompt } from '@/store/onboarding-script'
|
||||
|
||||
describe('plugins in the handoff runbook', () => {
|
||||
it('names every settled outcome and never tells the build agent to install', () => {
|
||||
const text = pluginsRunbook({
|
||||
pluginOutcomes: {
|
||||
blender: { detail: '', state: 'installed', tools: ['mcp__blender__a', 'mcp__blender__b'] },
|
||||
'nvidia-app': { detail: 'unavailable on darwin', state: 'failed', tools: [] },
|
||||
'nvidia-broadcast': { detail: '', state: 'skipped', tools: [] }
|
||||
},
|
||||
plugins: ['blender', 'nvidia-app', 'nvidia-broadcast', 'extra']
|
||||
})
|
||||
|
||||
expect(text).toContain('ready in this chat now: blender (2 tools)')
|
||||
expect(text).toMatch(/not installed: nvidia-app \(failed: unavailable on darwin\), nvidia-broadcast \(skipped/)
|
||||
expect(text).toContain('not offered for install: extra')
|
||||
expect(text).not.toMatch(/manage_catalog|hermes plugins install/)
|
||||
})
|
||||
|
||||
it('adds nothing when no plugin was picked', () => {
|
||||
expect(pluginsRunbook(DEFAULT_ANSWERS)).toBe('')
|
||||
expect(buildFirstTaskRunbook('Organize my work', DEFAULT_ANSWERS)).not.toContain('PLUGINS FROM ONBOARDING')
|
||||
})
|
||||
})
|
||||
|
||||
it('places the install beat after the first-task step and before the handoff line', () => {
|
||||
const prompt = buildChatOnboardingPrompt('Sid')
|
||||
const install = prompt.indexOf('THE INSTALL BEAT')
|
||||
|
||||
expect(install).toBeGreaterThan(prompt.indexOf('step="first"'))
|
||||
expect(install).toBeLessThan(prompt.indexOf('7. THE HANDOFF'))
|
||||
})
|
||||
@@ -153,6 +153,7 @@ export function buildFirstTaskRunbook(
|
||||
FIRST_USE_GUIDANCE,
|
||||
...planRunbook(plan, pluginRoot, connectFirst),
|
||||
...(connectFirst ? connectFirstRunbook(tools) : []),
|
||||
pluginsRunbook(answers),
|
||||
'While the work runs, place ::onboarding{step="progress" title="what you\'re doing"} as its own paragraph at the start of each status turn — the card shows the build breathing live. Keep the titles short and present-tense ("Scaffolding the project", "Wiring the reminder"). Emit each exactly like that, alone on its own line.',
|
||||
'When the first pass of the build is DONE: end that turn with ::ask{question="Does this match what you wanted?" options="Looks right|Change something|Take it further"} alone as its own paragraph, emitted EXACTLY as written. Act on their pick immediately. One unreviewed first output is how a build reads as broken; the ask is how it reads as a collaboration.',
|
||||
PLAIN_SPEECH
|
||||
@@ -180,6 +181,45 @@ function connectFirstRunbook(picks: string[]): string[] {
|
||||
]
|
||||
}
|
||||
|
||||
/** What the guide's install card settled (NS-960 D6). This session has no install tool by design (#119491), so
|
||||
* it never retries: it uses what is installed and names what is not. Empty when nothing was picked. */
|
||||
export function pluginsRunbook(answers: Pick<OnboardingAnswers, 'pluginOutcomes' | 'plugins'>): string {
|
||||
const outcomes = answers.pluginOutcomes ?? {}
|
||||
const names = [...new Set([...(answers.plugins ?? []), ...Object.keys(outcomes)])]
|
||||
|
||||
if (names.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const installed = names.filter(name => outcomes[name]?.state === 'installed')
|
||||
const offered = names.filter(name => outcomes[name] && outcomes[name].state !== 'installed')
|
||||
const notOffered = names.filter(name => !outcomes[name])
|
||||
|
||||
const ready = installed.map(name => {
|
||||
const count = outcomes[name].tools.length
|
||||
|
||||
return count ? `${name} (${count} tools)` : name
|
||||
})
|
||||
|
||||
const missed = offered.map(name => {
|
||||
const { detail, state } = outcomes[name]
|
||||
|
||||
return `${name} (${state === 'failed' ? `failed: ${detail || 'no reason given'}` : 'skipped by the user'})`
|
||||
})
|
||||
|
||||
return [
|
||||
'PLUGINS FROM ONBOARDING.',
|
||||
ready.length
|
||||
? `Installed during onboarding and ready in this chat now: ${ready.join(', ')}. Discover their tools with tool_search and use them when the task benefits; a tool whose app is not running reports that, say so plainly.`
|
||||
: '',
|
||||
missed.length ? `Offered during onboarding and not installed: ${missed.join(', ')}.` : '',
|
||||
notOffered.length ? `Picked during onboarding but not offered for install: ${notOffered.join(', ')}.` : '',
|
||||
'Do not install plugins yourself and do not ask to; if they want one later, they can add it from Settings, Plugins.'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** The machine-setup runbook. The audit comes before the plan because a plan written before looking is how an agent
|
||||
* installs a second copy of something, or "fixes" drivers that were already correct. */
|
||||
const MACHINE_SETUP_RUNBOOK = [
|
||||
|
||||
@@ -11,10 +11,21 @@ export interface OnboardingAnswers {
|
||||
committed: string[]
|
||||
connectors: string[]
|
||||
context: string
|
||||
/** Catalog plugin names picked on the connectors card. A pick is a wish, not an install. */
|
||||
plugins: string[]
|
||||
/** The settled install card's result per plugin, written when the guide's manage_catalog card settles. A
|
||||
* picked plugin with no entry was not offered for install (the chosen task did not need it). */
|
||||
pluginOutcomes: Record<string, PluginOutcome>
|
||||
name: string
|
||||
layout: string
|
||||
}
|
||||
|
||||
export interface PluginOutcome {
|
||||
state: 'failed' | 'installed' | 'skipped'
|
||||
detail: string
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
// Keep existing fork users' answers when they move to upstream.
|
||||
export const ANSWERS_KEY = 'hermes-onboarding-wizard-answers-v1'
|
||||
|
||||
@@ -24,7 +35,9 @@ export const DEFAULT_ANSWERS: OnboardingAnswers = {
|
||||
connectors: [],
|
||||
context: '',
|
||||
name: '',
|
||||
layout: 'basic'
|
||||
layout: 'basic',
|
||||
plugins: [],
|
||||
pluginOutcomes: {}
|
||||
}
|
||||
|
||||
export function loadAnswers(): OnboardingAnswers {
|
||||
@@ -38,7 +51,9 @@ export function loadAnswers(): OnboardingAnswers {
|
||||
connectors: raw?.connectors ?? [...DEFAULT_ANSWERS.connectors],
|
||||
context: raw?.context ?? DEFAULT_ANSWERS.context,
|
||||
name: raw?.name ?? DEFAULT_ANSWERS.name,
|
||||
layout: raw?.layout ?? DEFAULT_ANSWERS.layout
|
||||
layout: raw?.layout ?? DEFAULT_ANSWERS.layout,
|
||||
plugins: raw?.plugins ?? [],
|
||||
pluginOutcomes: raw?.pluginOutcomes ?? {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ export async function devResetOnboardingFlow(): Promise<void> {
|
||||
await $gateway.get()?.request('onboarding.reset_setup_profile', {})
|
||||
guideKickoff = { status: 'idle' }
|
||||
setPhase('idle')
|
||||
setOnboardingAnswers({ ...DEFAULT_ANSWERS, connectors: [...DEFAULT_ANSWERS.connectors] })
|
||||
setOnboardingAnswers({ ...DEFAULT_ANSWERS, connectors: [], plugins: [], pluginOutcomes: {} })
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
49
apps/desktop/src/store/onboarding-plugin-outcomes.test.ts
Normal file
49
apps/desktop/src/store/onboarding-plugin-outcomes.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
import type { ConnectionRequest, ConnectionTarget } from '@/store/connection-request'
|
||||
import { pluginOutcomesFrom } from '@/store/onboarding-plugin-outcomes'
|
||||
|
||||
const row = (
|
||||
name: string,
|
||||
state: ConnectionTarget['state'],
|
||||
kind: ConnectionTarget['kind'] = 'plugin'
|
||||
): ConnectionTarget => ({
|
||||
action: 'install',
|
||||
connectionId: '',
|
||||
connectUrl: null,
|
||||
detail: state === 'failed' ? 'nope' : '',
|
||||
discoveryError: null,
|
||||
instructions: null,
|
||||
kind,
|
||||
name,
|
||||
requiredEnv: [],
|
||||
state,
|
||||
tools: state === 'connected' ? ['mcp__x__y'] : []
|
||||
})
|
||||
|
||||
const request = (settled: boolean, targets: ConnectionTarget[]): ConnectionRequest => ({
|
||||
deadlineAt: 1,
|
||||
opId: 'op',
|
||||
seq: 1,
|
||||
sessionId: 's',
|
||||
settled,
|
||||
settledBy: settled ? 'continue' : null,
|
||||
targets,
|
||||
toolCallId: 't'
|
||||
})
|
||||
|
||||
it('maps a settled card to installed / failed / skipped per plugin row, whether or not the user acted', () => {
|
||||
const targets = [
|
||||
row('a', 'connected'),
|
||||
row('b', 'failed'),
|
||||
row('c', 'pending'),
|
||||
row('gmail', 'connected', 'connector')
|
||||
]
|
||||
|
||||
expect(pluginOutcomesFrom(request(false, targets))).toBeNull()
|
||||
expect(pluginOutcomesFrom(request(true, targets))).toEqual({
|
||||
a: { detail: '', state: 'installed', tools: ['mcp__x__y'] },
|
||||
b: { detail: 'nope', state: 'failed', tools: [] },
|
||||
c: { detail: '', state: 'skipped', tools: [] }
|
||||
})
|
||||
})
|
||||
52
apps/desktop/src/store/onboarding-plugin-outcomes.ts
Normal file
52
apps/desktop/src/store/onboarding-plugin-outcomes.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Carries the guide's install card result to the handoff (NS-960 D6). The guide calls manage_catalog once,
|
||||
* as the last beat before handoff; when that card settles, each plugin row's outcome is written into the
|
||||
* onboarding answers so the build session's runbook can say what is ready and what was offered and not
|
||||
* installed. The build agent has no install tool, so this record is the only way it learns either.
|
||||
*/
|
||||
import type { ConnectionRequest, ConnectionTarget } from '@/store/connection-request'
|
||||
import { $connectionRequests } from '@/store/connection-request'
|
||||
import { $onboardingAnswers, type PluginOutcome, setOnboardingAnswers } from '@/store/onboarding-answers'
|
||||
|
||||
const OUTCOME = { connected: 'installed', failed: 'failed' } as const satisfies Partial<
|
||||
Record<ConnectionTarget['state'], PluginOutcome['state']>
|
||||
>
|
||||
|
||||
const outcomeState = (state: ConnectionTarget['state']): PluginOutcome['state'] =>
|
||||
state === 'connected' || state === 'failed' ? OUTCOME[state] : 'skipped'
|
||||
|
||||
/** Plugin rows of a settled card, keyed by catalog name. Null while the card is open or has no plugin row. */
|
||||
export function pluginOutcomesFrom(request: ConnectionRequest): null | Record<string, PluginOutcome> {
|
||||
if (!request.settled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rows = request.targets.filter(target => target.kind === 'plugin')
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
rows.map(target => [target.name, { detail: target.detail, state: outcomeState(target.state), tools: target.tools }])
|
||||
)
|
||||
}
|
||||
|
||||
/** Record outcomes from the guide session's cards. Returns the unsubscribe. */
|
||||
export function watchPluginOutcomes(guideRuntimeId: () => null | string | undefined): () => void {
|
||||
return $connectionRequests.subscribe(requests => {
|
||||
const id = guideRuntimeId()
|
||||
const request = id ? requests[id] : undefined
|
||||
const outcomes = request ? pluginOutcomesFrom(request) : null
|
||||
|
||||
if (!outcomes) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $onboardingAnswers.get().pluginOutcomes
|
||||
|
||||
if (JSON.stringify({ ...current, ...outcomes }) !== JSON.stringify(current)) {
|
||||
setOnboardingAnswers({ pluginOutcomes: { ...current, ...outcomes } })
|
||||
}
|
||||
})
|
||||
}
|
||||
62
apps/desktop/src/store/onboarding-plugins.ts
Normal file
62
apps/desktop/src/store/onboarding-plugins.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The catalog plugins the onboarding card offers beside the hosted connectors (NS-960 D1, D4).
|
||||
*
|
||||
* The backend decides which entries are curated (`onboarding: true`) and which this OS runs, and judges
|
||||
* each app from the plugin's pinned declaration (`plugins.manage action=onboarding`). The card only
|
||||
* orders and draws them. A failed or missing RPC is an empty list: the connectors half still works.
|
||||
*/
|
||||
import type { OnboardingCatalogPlugin } from '@hermes/shared'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils'
|
||||
import { requestGatewayForAgent } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { isSessionOwnerRoute } from '@/store/session-request-router'
|
||||
|
||||
export type OnboardingPlugin = OnboardingCatalogPlugin
|
||||
|
||||
/** A plugin whose app is not on this machine stays pickable; the row says what is missing (D5). */
|
||||
export const pluginNeedsApp = (plugin: OnboardingPlugin): boolean => plugin.app_state === 'missing_app'
|
||||
|
||||
export function useOnboardingPlugins(storedId: null | string): OnboardingPlugin[] {
|
||||
const [plugins, setPlugins] = useState<OnboardingPlugin[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!storedId) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const ambientProfile = $activeGatewayProfile.get()
|
||||
|
||||
void resolveSessionOwner(storedId)
|
||||
.then(scope => {
|
||||
const connectionId = isSessionOwnerRoute(scope) ? scope.connectionId : null
|
||||
const profile = isSessionOwnerRoute(scope) ? scope.profile : scope || ambientProfile
|
||||
|
||||
return requestGatewayForAgent<{ onboarding?: OnboardingPlugin[] | null }>(
|
||||
connectionId,
|
||||
profile,
|
||||
'plugins.manage',
|
||||
{ action: 'onboarding' },
|
||||
20000
|
||||
)
|
||||
})
|
||||
.then(response => {
|
||||
if (!cancelled) {
|
||||
setPlugins(response.onboarding ?? [])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setPlugins([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [storedId])
|
||||
|
||||
return plugins
|
||||
}
|
||||
@@ -6,7 +6,14 @@
|
||||
* cannot be interpreted downstream.
|
||||
*/
|
||||
|
||||
import { machineKind, machineLanguageName, machineLooksNew, machineSetupLeads, machineUserName } from '@/store/machine'
|
||||
import {
|
||||
$machine,
|
||||
machineKind,
|
||||
machineLanguageName,
|
||||
machineLooksNew,
|
||||
machineSetupLeads,
|
||||
machineUserName
|
||||
} from '@/store/machine'
|
||||
|
||||
const VOICE_RULES =
|
||||
'Voice rules for EVERYTHING you write: plain declaratives in active voice. No em dashes (use commas or periods). No exclamation marks. Never praise the user. No AI diction (delve, seamless, robust, crucial, pivotal, landscape, testament, elevate, empower). No "not just X, it\'s Y" constructions. No forced lists of three. No generic closers ("you\'re all set", "happy to help", "the future looks bright") — end on the last real point. Contractions are fine. Specifics over adjectives.'
|
||||
@@ -58,6 +65,17 @@ export function machineForkOption(): string {
|
||||
return `Help me set up this ${machineKind()}`
|
||||
}
|
||||
|
||||
/** The plugin-backed first task and the catalog plugins it installs. A Windows PC with an NVIDIA GPU gets the
|
||||
* games and streaming job (NVIDIA App and Broadcast are Windows-only); every other computer gets Blender, which
|
||||
* the catalog offers on all three platforms. */
|
||||
export function pluginForkOption(): { label: string; plugins: string[] } {
|
||||
const machine = $machine.get()
|
||||
|
||||
return machine?.platform === 'win32' && machine.nvidia
|
||||
? { label: 'Set up my games and streaming', plugins: ['nvidia-app', 'nvidia-broadcast'] }
|
||||
: { label: 'Help me make something in Blender', plugins: ['blender'] }
|
||||
}
|
||||
|
||||
const SOMETHING_ELSE = 'Something else'
|
||||
|
||||
/** The look-around offer. The runbook places it in the turn after the layout step, because until the layout is
|
||||
@@ -96,14 +114,26 @@ export function forkOptions(): string[] {
|
||||
|
||||
return machineSetupLeads()
|
||||
? [machineForkOption(), SOMETHING_ELSE]
|
||||
: [mind, automate, machineForkOption(), figure, skip]
|
||||
: [mind, automate, machineForkOption(), pluginForkOption().label, figure, skip]
|
||||
}
|
||||
|
||||
/** What "Something else" opens onto. Empty when forkOptions() already listed every pill. */
|
||||
export function forkFallbackOptions(): string[] {
|
||||
const { automate, figure, mind, skip } = FORK_OPTIONS
|
||||
|
||||
return machineSetupLeads() ? [mind, automate, figure, skip] : []
|
||||
return machineSetupLeads() ? [mind, automate, pluginForkOption().label, figure, skip] : []
|
||||
}
|
||||
|
||||
/** The install beat: the last thing before the handoff card, and the only place the guide installs (NS-960 D2, D3).
|
||||
* The build session has no install tool, so a plugin the task needs must be in before the handoff. */
|
||||
function installBeat(pluginTask: { label: string; plugins: string[] }): string {
|
||||
return [
|
||||
'THE INSTALL BEAT, the last thing before the handoff card. The connectors note may list "plugins picked, not installed yet" (tools for this computer that Hermes installs and runs locally).',
|
||||
`Once the task is decided, pick the ones this task needs: all of them for a machine-setup job or a task that names the app; for "${pluginTask.label}", ${pluginTask.plugins.join(' and ')} count as picked even if they were not.`,
|
||||
'A picked plugin the task does not need is not offered; leave it. When none are needed, go straight to the handoff.',
|
||||
'Otherwise, in that turn: one short sentence, then ONE manage_catalog call with action="install" and items=[{"kind":"plugin","id":"<name>"}, ...] carrying every needed id as a batch, using the exact names from the note. The app shows one approval card with a row per plugin, and the call blocks until the user installs or skips each row or presses Continue. Never paste links or commands, never describe the Plugins tab, and never call install again for a row that already had a card.',
|
||||
'Use the settled result: name in one sentence what is now available (the installed rows and their tools) and say it works in the task chat that opens next; say in a clause what was not installed. Failed or skipped rows are recorded; do not re-offer them. Then, in that same turn, the handoff line.'
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
export function buildChatOnboardingPrompt(suggestedName?: string | null, signedIn = false, capabilities = ''): string {
|
||||
@@ -111,6 +141,7 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
const machine = machineForkOption()
|
||||
const fallback = forkFallbackOptions()
|
||||
const language = machineLanguageName()
|
||||
const pluginTask = pluginForkOption()
|
||||
|
||||
return [
|
||||
"You are Hermes, and this is a brand-new user's very first conversation with you. Your job right now is to get the app arranged around them and their first real job started.",
|
||||
@@ -142,7 +173,7 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
: []),
|
||||
'From there, walk them through setup conversationally, one turn each, in this order:',
|
||||
'1. This turn is exactly four things and then you stop: a few warm words about their name, then ::onboarding{step="name" value="THEIR_NAME"} on a line of its own (THEIR_NAME being the name they actually gave; it renders as nothing and just saves it), then one short sentence about their colour, then ::onboarding{step="look"} on a line of its own. That is one turn, not two, and it is not a conflict with RULE 3: the name line is not a question, the look card is, and it is the last thing you write.',
|
||||
'2. Then the apps they already use, so Hermes can connect to them later: one short sentence that makes clear what connecting means — you would read and act inside those apps for them (their inbox, their calendar, their repos), not message them there — then ::onboarding{step="connectors"} on a line of its own. Chat apps like Discord or Telegram are a different thing (how they reach you) and are not what this card is asking about; if they bring one up, say it lives in Messaging in the app’s settings and move on.',
|
||||
'2. Then the apps they already use, so Hermes can connect to them later: one short sentence that makes clear what connecting means — you would read and act inside those apps for them (their inbox, their calendar, their repos), not message them there — then ::onboarding{step="connectors"} on a line of its own. The card may also lead with plugins: tools for this computer that Hermes installs and runs locally; picking one only records it, so say that in the same sentence when the card could show one. Chat apps like Discord or Telegram are a different thing (how they reach you) and are not what this card is asking about; if they bring one up, say it lives in Messaging in the app’s settings and move on.',
|
||||
'CONNECTING, IF THEY ASK FOR IT HERE. The picks are preferences, not connections — but if at any point they ask you to connect an app, or say they want one wired up now, do it in this chat: call manage_connections action="status" once, then one action="connect" with EVERY app they named as a batch (connectors=["gmail","googlecalendar"], not one call per app). The app renders that as one card with a row per app and the call blocks until every app is connected, or the user presses Continue, or the deadline passes; never paste the links, never describe a settings page. The result lists each app as connected, skipped or not_connected; continue from that. Never call connect a second time for an app that already has a card. If an app is not in the status catalog, say so plainly. There is no Connectors page in Settings; do not send them to one.',
|
||||
// The only place sign-in is named before it is needed. It sits at the connectors step because the user has just
|
||||
// listed the accounts they use.
|
||||
@@ -176,6 +207,8 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
' If they pick that one, hand off with plan="plugin" on the handoff line.',
|
||||
` - "${FORK_OPTIONS.skip}": say one short line that the app is theirs and this chat stays here if they ever want a hand, then stand down. No more questions, no handoff.`,
|
||||
' Connector-dependent tasks are welcome. They do NOT need a no-account substitute: checking email should read their real email after permission, not build a mock inbox. Explain in one short clause that the task will offer to connect the needed app. If they decline or the integration is unavailable, keep the original task honest about being blocked and let them choose another task or supply the data themselves. Never invent personal data or silently change the goal.',
|
||||
` - "${pluginTask.label}": the task is decided. Carry it into the handoff brief as a concrete first project, and run the install beat for ${pluginTask.plugins.join(' and ')}.`,
|
||||
installBeat(pluginTask),
|
||||
'7. THE HANDOFF — you do not build the task in this conversation. Once the task is decided, reply with ONE short sentence framing it (you are giving the work its own chat so it has room, and this one stays open), then ::onboarding{step="handoff" task="short task name" brief="the build instruction, one sentence, written as the user\'s ask"} on a line of its own — task under 40 chars, brief under 200. Add plan="machine-setup" to that same line when the job is setting up their computer, or plan="plugin" when it is a piece of the Hermes interface. The app opens the session, moves the user into it, and starts the build from your brief.',
|
||||
'8. Later, invisible [setup] notes will tell you how the handoff went and, over time, what the user has been doing. When the handoff-complete note arrives, follow its instructions: one short line that you are around if they want a hand, then stop. If a handoff-failed note arrives instead, explain briefly that the first build did not start and point to Retry first build. Do not start another copy here or promise the build is running.',
|
||||
'Whenever you draft reusable text for them (an email, a pitch, a template, a post), put the draft in a fenced code block so they can copy it in one click — never inline in your prose. Your own commentary stays outside the block.',
|
||||
|
||||
@@ -1824,6 +1824,7 @@ export interface OnboardingAnswers {
|
||||
layout?: string | null
|
||||
focus?: string[] | null
|
||||
connectors?: string[] | null
|
||||
plugins?: string[] | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
export interface ProfilesRememberOnboardingResult {
|
||||
|
||||
@@ -17597,6 +17597,21 @@
|
||||
],
|
||||
"default": null,
|
||||
"title": "Connectors"
|
||||
},
|
||||
"plugins": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Plugins"
|
||||
}
|
||||
},
|
||||
"title": "OnboardingAnswers",
|
||||
|
||||
@@ -357,6 +357,7 @@ class OnboardingAnswers(Params):
|
||||
layout: str | None = None
|
||||
focus: list[str] | None = None
|
||||
connectors: list[str] | None = None
|
||||
plugins: list[str] | None = None
|
||||
# The onboarding store may carry extra UI-only keys; the writer ignores unknown ones.
|
||||
model_config = Params.model_config | {"extra": "allow"}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ def remember_onboarding(answers: dict) -> dict:
|
||||
raise ValueError(f'{key} must be text')
|
||||
if value and value.strip():
|
||||
facts.append(f'{label}: {value.strip()}')
|
||||
for key, label in (('focus', 'Focus areas'), ('connectors', 'Tools the user uses (not connection status)')):
|
||||
for key, label in (('focus', 'Focus areas'), ('connectors', 'Tools the user uses (not connection status)'),
|
||||
('plugins', 'Hermes plugins the user picked during onboarding (not install status)')):
|
||||
values = answers.get(key, [])
|
||||
if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
|
||||
raise ValueError(f'{key} must be a list of text')
|
||||
|
||||
Reference in New Issue
Block a user