Compare commits

...

2 Commits

Author SHA1 Message Date
Maanil Verma
be6a6ff8d1 fix exports 2026-07-09 07:36:38 +05:30
Maanil Verma
22e6746212 feat(cloud): game-style guided onboarding tour POC
Add an interactive, spotlight-driven onboarding tour that walks a new user
through an image-edit workflow on the canvas: connect nodes, type a prompt,
run it, watch it generate, and see the result — ending with a CTA to explore
more workflows.

- New self-contained module under renderer/extensions/onboardingTour
- Emerald agent cursor + speech bubble, SVG spotlight, animated ghost-drag line
- Real completion detection (wiring, execution_success, output image)
- Auto-starts on canvas ready; hardcoded to flux_kontext_dev_basic (POC)
- HANDOFF.md documents the flow, integrations, and next steps

POC only: no gating, persistence, or tests.
2026-07-08 20:19:18 +05:30
6 changed files with 956 additions and 0 deletions

View File

@@ -4496,5 +4496,22 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"onboardingTour": {
"guide": "Guide",
"next": "Next",
"back": "Back",
"finish": "Finish",
"skip": "Skip tour",
"hint": {
"welcome": "This is your starting image. In a minute you'll remix it into something totally new.",
"connect": "Grab this green dot and drag it over to the editor — that's how nodes talk to each other.",
"prompt": "Here's the fun part. Tell it what you want — a style, a scene, anything. Then hit Next.",
"save": "Whatever you create lands right here.",
"run": "Alright, moment of truth — hit Run and watch it come to life.",
"generating": "Working on it… this is where the magic happens.",
"result": "Here's your edited image, saved and ready. This was just one of hundreds of things you can build."
},
"exploreCta": "Explore workflows"
}
}

View File

@@ -0,0 +1,138 @@
# Onboarding Tour POC — Handoff
> Branch: `feat/onboarding-tour-poc` (cut from `main`). This is a **POC / prototype** — hardcoded, no
> gating, no tests. Goal: a game-style guided onboarding tour on the ComfyUI cloud canvas that walks a
> zero-knowledge user to their first "aha" (create an image) and hooks them toward more workflows.
## What this is
When the canvas loads, a hand-held, video-game-style tutorial runs on top of a real template. It uses the
**spotlight + coach-mark + progressive-disclosure** pattern: dim the canvas, spotlight one node at a time,
show an emerald **agent cursor + speech bubble** pointing at the target, and let the user perform real
actions (drag to connect, type a prompt, click Run). It carries through generation and celebrates the
result, then offers a CTA to explore more workflows.
## How to run it
```bash
pnpm dev:cloud:staging # points DEV_SERVER_COMFYUI_URL at https://stagingcloud.comfy.org/
# open http://localhost:5173/ (sign in to staging if prompted)
```
The tour **auto-starts on canvas ready** (no gating — this is intentional for the POC). It's wired in
`src/views/GraphView.vue` (`onboardingTour.start()` in `onGraphReady`, and `<OnboardingTourOverlay />`
mounted among the global overlays).
- `pnpm dev:cloud:test` → testcloud; `pnpm dev:cloud:staging` → staging. Both proxy `/templates/*` + the
API to that backend.
- **Real generation (the `generating``result` beats) needs a working Run.** On staging/test Run is
currently the paywalled "Subscribe to Run" button, so `execution_success` won't fire — see "Grace
fallback" below. A **follow-up PR will enable free-user Run**; once it lands, the live progress bar +
real result image light up automatically.
## Files (all new unless noted)
- `src/renderer/extensions/onboardingTour/tourSteps.ts`**the "level design".** Declarative `tourSteps[]`
- the `TourStep` discriminated union. Hardcoded template constants: `TOUR_TEMPLATE_ID =
'flux_kontext_dev_basic'`, `EDIT_ENGINE_TYPE` (the subgraph node's type id),
`RUN_BUTTON_SELECTOR`.
- `src/renderer/extensions/onboardingTour/useOnboardingTour.ts` — **the controller** (shared composable).
Owns `stepIndex`/`isActive`, loads + simplifies the graph, drives per-step spotlight/completion, exposes
everything the overlay renders.
- `src/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue` — **the presentation.** Teleports to
`body`, `fixed inset-0 z-3000`. SVG spotlight mask (dim + cutouts), emerald node outlines, ghost-drag
SVG line, agent cursor + bubble, progress dots, skip. No business logic.
- `src/locales/en/main.json` — `onboardingTour` block (all copy + `hint.*` per step `key`).
- `src/views/GraphView.vue` — **edited**: import + mount overlay, auto-start in `onGraphReady`.
Lives under `renderer/extensions/` (not `platform/`) because it drives litegraph directly — `platform/`
can't import the canvas store (layer-architecture ESLint rule).
## The flow (why it's shaped this way)
Sequenced for **momentum → ownership → aha → expansion**, NOT a feature tour (deliberately dropped
canvas-intro, node-selection trivia, model dropdown, and a left-menu tour — those are power features best
revealed just-in-time later). Product rationale is in the chat; the short version: the tour's job is to get
a nervous newcomer to their first successful result feeling "I made that."
1. **welcome** (reveal, manual Next) — points at Load Image: "This is your starting image…"
2. **connect** (drag, auto) — pre-clears the LoadImage→Edit link, ghost line + cursor guide the drag;
advances when the input is re-linked.
3. **prompt** (type, manual Next) — focuses the Edit node's `text` widget (prefilled demo prompt kept);
user types freely, hits Next. **Typing does NOT auto-advance** (continuous action → manual).
4. **save** (reveal, manual Next) — points at Save Image: "Whatever you create lands right here."
5. **run** (click, auto) — cursor jumps to the top-bar Run button; advances on click.
6. **generating** (auto) — spotlights the **Edit node** (where the blurry live preview renders), shows a
live progress bar; advances on the real `execution_success` event.
7. **result** (last, manual) — spotlights the **Save node**, shows the **final image inline** + a
prominent **"Explore workflows →" CTA** that opens the template modal (`useWorkflowTemplateSelectorDialog`)
and ends the tour.
### Step-kind → behavior
- `reveal` / `result`: talk-steps, wait for **Next**.
- `connect` / `run` / `generating`: action-steps (`auto: true`), advance on the real action/event.
- **Grace fallback**: any `auto` step that hasn't completed in **8s** flips `autoStepStalled` → shows a
subtle Next so the user is never trapped (critical while Run is paywalled and `generating` can't
complete). A `connect` step that couldn't pre-clear its link also shows Next.
## Key integrations (already wired, verified in-repo)
- **Template load**: `useTemplateWorkflows().loadTemplates()` + `loadWorkflowTemplate('flux_kontext_dev_basic', 'default')`.
- **Graph simplify**: `simplifyGraph()` removes the duplicate LoadImage + all MarkdownNotes, caps oversized
node widths, lays the 3 kept nodes (Load → Edit → Save) in a clean row. `frameWorkflow()` fits at
`zoom: 0.85`.
- **Node resolution**: `resolveNode(type, occ)` against `canvasStore.currentGraph`.
- **Connect detection**: poll `toNode.getInputNode(slot)` (robust across subgraph nodes; event-agnostic).
- **Ghost line hides during drag**: `app.canvas.linkConnector.isConnecting`.
- **Run detection**: capture-phase `click` listener matching `RUN_BUTTON_SELECTOR`
(`[data-testid="queue-button"], [data-testid="subscribe-to-run-button"]`).
- **Generation**: `api.addEventListener('execution_success', …)`; progress from
`useExecutionStore().executionProgress` (01 → %). Result image via
`useNodeOutputStore().getNodeImageUrls(saveNode)[0]`.
- **Explore CTA**: `useWorkflowTemplateSelectorDialog().show('command', { initialCategory: 'all' })`, then
`stop()`. Overlay uses `showOverlay` so it never sheets over the modal.
## Design / theme
- Accent: **emerald** (`emerald-400/500`, `#34d399`) — deliberately distinct from ComfyUI's node/link
blues so it reads as "tutorial UI." (Earlier used azure `#31b9f4`; user rejected — clashed with nodes.)
- Bubble: sleek dark glass (`bg-neutral-900/85 backdrop-blur-md`, emerald hairline border), "GUIDE" label,
minimal "Next →" text link (user rejected the earlier chunky white pill). Result step swaps in a solid
emerald CTA button.
- Dim: `rgba(10, 12, 16, 0.66)` neutral cool-black (earlier greenish `rgba(6,10,8,0.72)` was murkier).
- Ghost-drag line: emerald dashed, marching-ants via a scoped `@keyframes` (only place a `<style>` block is
used; precedented in the repo — Tailwind has no stroke-dashoffset util).
## Known limitations / next steps (POC debt, intentional)
1. **Free-run dependency** — `generating`/`result` only show live progress + the real image once free-user
Run works (follow-up PR). Until then they fall back to Next after 8s. **This is the #1 thing to finish
for the full aha.**
2. **Prompt lives inside the subgraph** — the `flux_kontext` template's editable prompt is a promoted
widget on the subgraph node; the type step targets it and focuses the reachable DOM textarea. If the
widget renders on the legacy canvas (no DOM input), auto-focus won't fire (Next still works). For prod,
consider a template whose prompt is a first-class **surface** widget (e.g. `image_flux2_klein_text_to_image`
has a `PrimitiveStringMultiline` surface prompt) — see chat for the template survey.
3. **Own input (parked)** — the biggest remaining product lever: let the user upload their own image / type
their own idea (ownership → retention). Deferred per user.
4. **Scalability** — the _pattern_ scales (data-driven step list + reusable primitives `connect`/`type`/
`run`/`reveal`/`generating`/`result`). To port to another template: swap the step list + generalize
`resolveNode`/`simplifyGraph` from hardcoded node types to ids/roles. Contained refactor, not a rewrite.
Real end-state is a small per-template tour-definition format; you'd only tour the 510 hero templates,
not all 200.
5. **Graph is mutated** — `simplifyGraph` deletes nodes / relayouts on start; the user is left with the
trimmed graph when the tour ends. Fine for POC.
6. **No gating / no persistence / no tests** — POC. Auto-starts every canvas load.
## Gotchas for the next session
- The overlay teleports to `body` (not `#graph-canvas-container`) so it can point at the top-bar Run button;
all coords are computed in **client space** from `app.canvas.canvas.getBoundingClientRect()` + `ds.offset/scale`.
- HMR on the overlay throws a benign `Cannot read properties of null (reading 'flags')` / `nextSibling` in
the dev console — it's a hot-reload reconciliation artifact, **not** a cold-load bug. Verify on a fresh
page load, not after an HMR patch.
- `hintLabel` keys copy by each step's **`key`** field (not `kind`) — every step's `key` must have a
matching `onboardingTour.hint.<key>` entry or you'll see the raw key string.
- Standards: no `dark:`, `!important`, arbitrary %/px where a util exists, `:class="[]"` (use `cn()`),
PrimeVue, barrel files. `pnpm format` before committing.

View File

@@ -0,0 +1,335 @@
<template>
<Teleport v-if="showOverlay" to="body">
<div class="pointer-events-none fixed inset-0 z-3000" aria-live="polite">
<!-- Dim everything except soft cutouts around the lit target(s). -->
<svg class="absolute inset-0 size-full" aria-hidden="true">
<defs>
<mask id="onboarding-spotlight">
<rect width="100%" height="100%" fill="white" />
<rect
v-for="(hole, i) in litRects"
:key="i"
:x="hole.left - 10"
:y="hole.top - 10"
:width="hole.width + 20"
:height="hole.height + 20"
rx="16"
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="rgba(10, 12, 16, 0.66)"
mask="url(#onboarding-spotlight)"
/>
</svg>
<!-- Emerald outline around each lit target. -->
<div
v-for="(hole, i) in litRects"
:key="i"
class="absolute rounded-2xl ring-2 ring-emerald-400/80 transition-all duration-500 ease-out"
:style="rectStyle(hole)"
/>
<!-- Ghost drag line for connect steps. -->
<svg
v-if="dragPath"
class="absolute inset-0 size-full"
fill="none"
aria-hidden="true"
>
<path
:d="dragPath"
stroke="#34d399"
stroke-width="3"
stroke-linecap="round"
stroke-dasharray="7 9"
class="onboarding-drag-line"
/>
<circle r="7" fill="#34d399">
<animateMotion dur="1.5s" repeatCount="indefinite" :path="dragPath" />
</circle>
</svg>
<!-- Agent cursor + speech bubble, pointing at the target. -->
<div
v-if="cursorPos"
class="pointer-events-auto absolute transition-all duration-500 ease-out"
:style="cursorStyle"
>
<i
class="icon-[lucide--mouse-pointer-2] size-5 rotate-[-8deg] text-emerald-400 drop-shadow-[0_1px_4px_rgba(52,211,153,0.5)]"
aria-hidden="true"
/>
<div
class="absolute top-4 left-5 w-64 rounded-2xl rounded-tl-md border border-emerald-400/25 bg-neutral-900/85 p-3 text-white shadow-2xl backdrop-blur-md"
>
<div class="mb-2 flex items-center gap-1.5">
<span class="size-1.5 rounded-full bg-emerald-400" />
<span
class="text-[11px] font-semibold tracking-wide text-emerald-400/90 uppercase"
>
{{ t('onboardingTour.guide') }}
</span>
</div>
<p class="text-sm/relaxed text-white/90">{{ hintLabel }}</p>
<!-- Live progress while the image generates -->
<div
v-if="currentStep?.kind === 'generating'"
class="mt-3"
aria-hidden="true"
>
<div class="h-1.5 overflow-hidden rounded-full bg-white/10">
<div
class="h-full rounded-full bg-emerald-400 transition-all duration-300"
:style="{ width: `${Math.max(executionProgress, 6)}%` }"
/>
</div>
</div>
<!-- The result the user created -->
<img
v-if="currentStep?.kind === 'result' && resultImageUrl"
:src="resultImageUrl"
class="mt-3 aspect-square w-full rounded-lg object-cover"
alt=""
/>
<!-- Result step: a prominent CTA to explore more workflows -->
<button
v-if="currentStep?.kind === 'result'"
type="button"
class="group mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg bg-emerald-500 px-3 py-2 text-sm font-semibold text-white transition-colors hover:bg-emerald-400"
@click="openExplore"
>
{{ t('onboardingTour.exploreCta') }}
<i
class="icon-[lucide--arrow-right] size-4 transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
<div v-else class="mt-3 flex items-center justify-between">
<button
v-if="canGoBack"
type="button"
class="text-xs text-white/45 transition-colors hover:text-white/80"
@click="back"
>
{{ t('onboardingTour.back') }}
</button>
<span v-else />
<button
v-if="canGoNext"
type="button"
class="group flex items-center gap-1 text-xs font-medium text-emerald-400 transition-colors hover:text-emerald-300"
@click="next"
>
{{
isLast ? t('onboardingTour.finish') : t('onboardingTour.next')
}}
<i
class="icon-[lucide--arrow-right] size-3.5 transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</div>
</div>
</div>
<!-- Progress dots + skip -->
<div
class="absolute bottom-6 left-1/2 flex -translate-x-1/2 flex-col items-center gap-2"
>
<div class="flex items-center gap-1.5">
<span
v-for="i in totalSteps"
:key="i"
:class="
cn(
'h-1.5 rounded-full transition-all duration-300',
i - 1 === stepIndex ? 'w-6 bg-emerald-400' : 'w-1.5 bg-white/25'
)
"
/>
</div>
<button
type="button"
class="pointer-events-auto text-xs text-white/45 transition-colors hover:text-white/80"
@click="stop"
>
{{ t('onboardingTour.skip') }}
</button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useRafFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import type { ConnectStep } from './tourSteps'
import { useOnboardingTour } from './useOnboardingTour'
const { t } = useI18n()
const {
isActive,
stepIndex,
totalSteps,
currentStep,
isLast,
focusNode,
litNodes,
domTargetSelector,
canGoNext,
canGoBack,
resultImageUrl,
executionProgress,
resolveNode,
next,
back,
stop,
openExplore
} = useOnboardingTour()
const showOverlay = computed(() => isActive.value && !!currentStep.value)
interface ScreenRect {
left: number
top: number
width: number
height: number
}
const litRects = ref<ScreenRect[]>([])
const cursorPos = ref<{ x: number; y: number } | null>(null)
const dragPath = ref('')
/** Client-space origin of the litegraph canvas (nodes live in canvas coords). */
function canvasFrame() {
const canvas = app.canvas
if (!canvas) return null
const rect = canvas.canvas.getBoundingClientRect()
const { offset, scale } = canvas.ds
return { rect, offset, scale }
}
/** Node canvas coords -> client (viewport) coords. */
function nodeClientRect(node: LGraphNode): ScreenRect | null {
const f = canvasFrame()
if (!f) return null
const [bx, by, bw, bh] = node.boundingRect
return {
left: f.rect.left + (bx + f.offset[0]) * f.scale,
top: f.rect.top + (by + f.offset[1]) * f.scale,
width: bw * f.scale,
height: bh * f.scale
}
}
function domClientRect(selector: string): ScreenRect | null {
const el = document.querySelector(selector)
if (!el) return null
const r = el.getBoundingClientRect()
return { left: r.left, top: r.top, width: r.width, height: r.height }
}
function slotClient(pos: [number, number]): [number, number] {
const f = canvasFrame()
if (!f) return [0, 0]
return [
f.rect.left + (pos[0] + f.offset[0]) * f.scale,
f.rect.top + (pos[1] + f.offset[1]) * f.scale
]
}
useRafFn(() => {
const step = currentStep.value
if (!step) return
const domSel = domTargetSelector.value
if (domSel) {
const r = domClientRect(domSel)
litRects.value = r ? [r] : []
cursorPos.value = r
? { x: r.left + r.width / 2, y: r.top + r.height + 6 }
: null
dragPath.value = ''
return
}
litRects.value = litNodes.value
.map((n) => nodeClientRect(n))
.filter((r): r is ScreenRect => !!r)
if (step.kind === 'connect') {
// Point the cursor right at the output slot the user drags from.
const from = resolveNode(step.targetNodeType, step.targetNodeOccurrence)
const slot = from ? slotClient(from.getOutputPos(step.fromSlot)) : null
cursorPos.value = slot ? { x: slot[0] + 6, y: slot[1] + 6 } : null
// Hide the guide line the moment the user grabs a link, so their real drag
// line is the only one visible.
const dragging = app.canvas?.linkConnector.isConnecting ?? false
dragPath.value = dragging ? '' : buildDragPath(step)
return
}
const focus = focusNode.value
const fr = focus ? nodeClientRect(focus) : null
cursorPos.value = fr
? { x: fr.left + fr.width * 0.5, y: fr.top + fr.height + 8 }
: null
dragPath.value = ''
})
function buildDragPath(step: ConnectStep): string {
const fromNode = resolveNode(step.targetNodeType, step.targetNodeOccurrence)
const toNode = resolveNode(step.toNodeType, step.toNodeOccurrence)
if (!fromNode || !toNode) return ''
const [x1, y1] = slotClient(fromNode.getOutputPos(step.fromSlot))
const [x2, y2] = slotClient(toNode.getInputPos(step.toSlot))
const cx = (x1 + x2) / 2
return `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`
}
function rectStyle(r: ScreenRect) {
return {
left: `${r.left - 8}px`,
top: `${r.top - 8}px`,
width: `${r.width + 16}px`,
height: `${r.height + 16}px`
}
}
const cursorStyle = computed(() => {
const c = cursorPos.value
if (!c) return {}
return { left: `${c.x}px`, top: `${c.y}px` }
})
const hintLabel = computed(() => {
const step = currentStep.value
return step ? t(`onboardingTour.hint.${step.key}`) : ''
})
</script>
<style scoped>
.onboarding-drag-line {
animation: onboarding-dash 0.6s linear infinite;
}
@keyframes onboarding-dash {
to {
stroke-dashoffset: -16;
}
}
</style>

View File

@@ -0,0 +1,133 @@
type TourStepKind =
| 'reveal'
| 'type'
| 'connect'
| 'run'
| 'generating'
| 'result'
| 'done'
interface TourStepBase {
kind: TourStepKind
/** i18n key suffix under `onboardingTour.hint` for the coach-mark copy. */
key: string
/** Node type to spotlight; resolved to a live node id at runtime. */
targetNodeType?: string
/** When a template has duplicates of a type, pick the Nth (0-based). */
targetNodeOccurrence?: number
/** CSS selector for a DOM target (e.g. a toolbar button) instead of a node. */
domTarget?: string
/** Whether the step advances on its own action; talk-steps use Next. */
auto?: boolean
}
interface RevealStep extends TourStepBase {
kind: 'reveal'
}
interface DoneStep extends TourStepBase {
kind: 'done'
}
interface TypeStep extends TourStepBase {
kind: 'type'
/** Widget name on the node whose value the user must fill in, if any. */
widgetName?: string
}
/** Point at a DOM element (e.g. the Run button) and advance when it is clicked. */
interface RunStep extends TourStepBase {
kind: 'run'
domTarget: string
}
/** Waits, showing live progress, until the workflow finishes generating. */
interface GeneratingStep extends TourStepBase {
kind: 'generating'
}
/** Celebrates the produced image with a CTA to explore more workflows. */
interface ResultStep extends TourStepBase {
kind: 'result'
}
/**
* The user must drag a link from `targetNodeType`'s output slot into the input
* slot below. On tour start this exact link is pre-cleared so the drag is real.
*/
export interface ConnectStep extends TourStepBase {
kind: 'connect'
targetNodeType: string
fromSlot: number
toNodeType: string
toNodeOccurrence?: number
toSlot: number
}
export type TourStep =
| RevealStep
| TypeStep
| ConnectStep
| RunStep
| GeneratingStep
| ResultStep
| DoneStep
export const TOUR_TEMPLATE_ID = 'flux_kontext_dev_basic'
export const TOUR_TEMPLATE_SOURCE = 'default'
/** The Image Edit (Flux.1 Kontext Dev) subgraph node — one box that does the edit. */
export const EDIT_ENGINE_TYPE = '654c828f-2572-47e8-ba85-8a832c89b30c'
/** Run button in the top bar; the cloud paywalled variant falls back to this list. */
const RUN_BUTTON_SELECTOR =
'[data-testid="queue-button"], [data-testid="subscribe-to-run-button"]'
/**
* Beginner image-edit flow, sequenced for momentum → ownership → aha:
* intro → wire it up → make it yours (prompt) → run → see your result.
*/
export const tourSteps: TourStep[] = [
{
kind: 'reveal',
key: 'welcome',
targetNodeType: 'LoadImage'
},
{
kind: 'connect',
key: 'connect',
targetNodeType: 'LoadImage',
fromSlot: 0,
toNodeType: EDIT_ENGINE_TYPE,
toSlot: 0,
auto: true
},
{
kind: 'type',
key: 'prompt',
targetNodeType: EDIT_ENGINE_TYPE,
widgetName: 'text'
},
{
kind: 'reveal',
key: 'save',
targetNodeType: 'SaveImage'
},
{
kind: 'run',
key: 'run',
domTarget: RUN_BUTTON_SELECTOR,
auto: true
},
{
kind: 'generating',
key: 'generating',
targetNodeType: EDIT_ENGINE_TYPE,
auto: true
},
{
kind: 'result',
key: 'result',
targetNodeType: 'SaveImage'
}
]

View File

@@ -0,0 +1,326 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, shallowRef } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { ConnectStep, TourStep } from './tourSteps'
import {
EDIT_ENGINE_TYPE,
TOUR_TEMPLATE_ID,
TOUR_TEMPLATE_SOURCE,
tourSteps
} from './tourSteps'
interface ClearedLink {
fromNodeId: LGraphNode['id']
fromSlot: number
toNode: LGraphNode
toSlot: number
}
function useOnboardingTourImpl() {
const canvasStore = useCanvasStore()
const templateWorkflows = useTemplateWorkflows()
const executionStore = useExecutionStore()
const nodeOutputStore = useNodeOutputStore()
const templateDialog = useWorkflowTemplateSelectorDialog()
const isActive = ref(false)
const stepIndex = ref(0)
const clearedLink = shallowRef<ClearedLink | null>(null)
const resultImageUrl = ref<string | null>(null)
let detachStep: (() => void) | null = null
const currentStep = computed<TourStep | null>(() =>
isActive.value ? (tourSteps[stepIndex.value] ?? null) : null
)
const totalSteps = computed(() => tourSteps.length)
const isLast = computed(() => stepIndex.value === tourSteps.length - 1)
function resolveNode(type?: string, occurrence = 0): LGraphNode | null {
const graph = canvasStore.currentGraph
if (!graph || !type) return null
const matches = graph.nodes.filter((node) => node.type === type)
return matches[occurrence] ?? null
}
/** The node the cursor points at (its rect anchors the coach mark). */
const focusNode = computed(() => {
const step = currentStep.value
return step
? resolveNode(step.targetNodeType, step.targetNodeOccurrence)
: null
})
/** The DOM element a step points at (Run button), if it targets one. */
const domTargetSelector = computed(() => currentStep.value?.domTarget ?? null)
/** Nodes kept fully lit this step; all others stay dimmed the whole time. */
const litNodes = computed<LGraphNode[]>(() => {
const step = currentStep.value
if (!step) return []
const nodes = [resolveNode(step.targetNodeType, step.targetNodeOccurrence)]
if (step.kind === 'connect') {
nodes.push(resolveNode(step.toNodeType, step.toNodeOccurrence))
}
return nodes.filter((n): n is LGraphNode => !!n)
})
/**
* Talk-steps wait for Next; action-steps advance on their own action. A
* connect step that couldn't pre-clear its link (nothing to drag), or any
* auto-step that hasn't completed within a grace period, also shows Next so
* the user is never trapped (e.g. a run that never executes).
*/
const autoStepStalled = ref(false)
const canGoNext = computed(() => {
const step = currentStep.value
if (!step?.auto) return true
if (step.kind === 'connect' && !clearedLink.value) return true
return autoStepStalled.value
})
const canGoBack = computed(() => isActive.value && stepIndex.value > 0)
function stepTargetNode(step: TourStep): LGraphNode | null {
return resolveNode(step.targetNodeType, step.targetNodeOccurrence)
}
function preClearConnectLink(step: ConnectStep) {
const fromNode = stepTargetNode(step)
const toNode = resolveNode(step.toNodeType, step.toNodeOccurrence)
if (!fromNode || !toNode) return
toNode.disconnectInput(step.toSlot, true)
// Only gate the step on a real reconnect if the disconnect actually took.
if (toNode.getInputNode(step.toSlot)) return
clearedLink.value = {
fromNodeId: fromNode.id,
fromSlot: step.fromSlot,
toNode,
toSlot: step.toSlot
}
app.canvas?.setDirty(true, true)
}
function restoreClearedLink() {
const link = clearedLink.value
if (!link) return
const fromNode = app.canvas?.graph?.getNodeById(link.fromNodeId)
if (fromNode && !link.toNode.getInputNode(link.toSlot)) {
fromNode.connect(link.fromSlot, link.toNode, link.toSlot)
app.canvas?.setDirty(true, true)
}
clearedLink.value = null
}
function listenForCompletion(step: TourStep): () => void {
// The prompt step lets the user type freely and move on with Next — typing
// is continuous, so keystrokes must not auto-advance mid-thought.
if (step.kind === 'connect') {
const toNode = resolveNode(step.toNodeType, step.toNodeOccurrence)
// Only advance once the input goes from empty (we pre-cleared it) to linked
// again — guards against a stale/undetected pre-clear firing instantly.
const poll = window.setInterval(() => {
if (!clearedLink.value) return
if (toNode?.getInputNode(step.toSlot)) {
clearedLink.value = null
next()
}
}, 200)
return () => window.clearInterval(poll)
}
if (step.kind === 'run') {
const onClick = (event: MouseEvent) => {
if ((event.target as Element | null)?.closest(step.domTarget)) next()
}
document.addEventListener('click', onClick, true)
return () => document.removeEventListener('click', onClick, true)
}
if (step.kind === 'generating') {
const onSuccess = () => {
captureResultImage()
next()
}
api.addEventListener('execution_success', onSuccess)
return () => api.removeEventListener('execution_success', onSuccess)
}
// reveal / result wait for the user to press Next / the explore CTA.
return () => {}
}
/** The result step's CTA: open the template library, then end the tour. */
function openExplore() {
templateDialog.show('command', { initialCategory: 'all' })
stop()
}
/** The finished image lands on the Save node — capture it for the result step. */
function captureResultImage() {
const saveNode = resolveNode('SaveImage', 0)
if (!saveNode) return
resultImageUrl.value =
nodeOutputStore.getNodeImageUrls(saveNode)?.[0] ?? null
}
function focusTypeInput(step: TourStep) {
if (step.kind !== 'type') return
const node = stepTargetNode(step)
if (!node) return
requestAnimationFrame(() => {
document
.querySelector<HTMLTextAreaElement | HTMLInputElement>(
`[data-node-id="${node.id}"] textarea, [data-node-id="${node.id}"] input`
)
?.focus()
})
}
function enterStep() {
detachStep?.()
detachStep = null
const step = currentStep.value
if (!step) return
if (step.kind === 'connect') preClearConnectLink(step)
focusTypeInput(step)
app.canvas?.setDirty(true, true)
autoStepStalled.value = false
const graceTimer = step.auto
? window.setTimeout(() => (autoStepStalled.value = true), 8000)
: undefined
const detachCompletion = listenForCompletion(step)
detachStep = () => {
window.clearTimeout(graceTimer)
detachCompletion()
}
}
function next() {
if (!isActive.value) return
if (isLast.value) {
stop()
return
}
stepIndex.value += 1
enterStep()
}
function back() {
if (!isActive.value || stepIndex.value === 0) return
detachStep?.()
detachStep = null
restoreClearedLink()
stepIndex.value -= 1
enterStep()
}
/**
* Strip the loaded template down to a clean teaching line: keep the first
* LoadImage, the edit engine, and SaveImage; drop notes, the duplicate
* LoadImage, and any stray node, then lay the three out evenly. A newcomer
* sees three clear boxes, not a busy production graph.
*/
function simplifyGraph() {
const graph = canvasStore.currentGraph
if (!graph) return
const keep = new Set<LGraphNode>()
const load = resolveNode('LoadImage', 0)
const edit = resolveNode(EDIT_ENGINE_TYPE, 0)
const save = resolveNode('SaveImage', 0)
for (const node of [load, edit, save]) if (node) keep.add(node)
for (const node of [...graph.nodes]) {
if (!keep.has(node)) graph.remove(node)
}
// Balance the row: cap the very wide Save node so nothing dominates the frame.
const row = [load, edit, save].filter((n): n is LGraphNode => !!n)
let x = 0
for (const node of row) {
node.size = [Math.min(node.size[0], 320), node.size[1]]
node.pos = [x, 0]
x += node.size[0] + 80
}
app.canvas?.setDirty(true, true)
}
/** Frame the whole small workflow once, gently — no per-node zoom. */
function frameWorkflow() {
const canvas = app.canvas
const nodes = canvas?.graph?.nodes ?? []
if (!canvas || !nodes.length) return
for (const node of nodes) {
node.updateArea()
}
const bounds = createBounds(nodes)
if (bounds) canvas.ds.fitToBounds(bounds, { zoom: 0.85 })
canvas.setDirty(true, true)
}
async function start() {
if (isActive.value) return
await templateWorkflows.loadTemplates()
await templateWorkflows.loadWorkflowTemplate(
TOUR_TEMPLATE_ID,
TOUR_TEMPLATE_SOURCE
)
simplifyGraph()
frameWorkflow()
isActive.value = true
stepIndex.value = 0
enterStep()
}
function stop() {
detachStep?.()
detachStep = null
restoreClearedLink()
app.canvas?.setDirty(true, true)
isActive.value = false
stepIndex.value = 0
resultImageUrl.value = null
}
const executionProgress = computed(() =>
Math.round(executionStore.executionProgress * 100)
)
return {
isActive,
stepIndex,
totalSteps,
currentStep,
isLast,
focusNode,
litNodes,
domTargetSelector,
canGoNext,
canGoBack,
resultImageUrl,
executionProgress,
resolveNode,
start,
next,
back,
stop,
openExplore
}
}
export const useOnboardingTour = createSharedComposable(useOnboardingTourImpl)

View File

@@ -20,6 +20,7 @@
</template>
</div>
<OnboardingTourOverlay />
<GlobalToast />
<InviteAcceptedToast />
<RerouteMigrationToast />
@@ -47,6 +48,8 @@ import {
import { runWhenGlobalIdle } from '@/base/common/async'
import MenuHamburger from '@/components/MenuHamburger.vue'
import OnboardingTourOverlay from '@/renderer/extensions/onboardingTour/OnboardingTourOverlay.vue'
import { useOnboardingTour } from '@/renderer/extensions/onboardingTour/useOnboardingTour'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
@@ -293,7 +296,11 @@ void nextTick(() => {
})
})
const onboardingTour = useOnboardingTour()
const onGraphReady = () => {
void onboardingTour.start()
runWhenGlobalIdle(() => {
// Track user login when app is ready in graph view (cloud only)
if (isCloud && authStore.isAuthenticated && !hasTrackedLogin) {