diff --git a/docs/issues/guided-onboarding-first-chat-confirm/plan.md b/docs/issues/guided-onboarding-first-chat-confirm/plan.md new file mode 100644 index 000000000..56b9aab8e --- /dev/null +++ b/docs/issues/guided-onboarding-first-chat-confirm/plan.md @@ -0,0 +1,17 @@ +# Implementation Plan + +## Cause + +`NewThreadPage` handles the `first-chat` confirm action by querying the contenteditable element and calling `HTMLElement.focus()`. The chat input is backed by a TipTap editor, so the editor-aware focus path should be invoked through the component itself. + +## Change + +- Expose a `focusInput` method from `ChatInputBox` that focuses and scrolls the editor into view. +- Use that exposed method from `NewThreadPage` when the guided `first-chat` confirm action runs. +- Keep the existing DOM query as a fallback for older or stubbed component shapes. +- Add a focused renderer test for the confirm action. + +## Validation + +- Run focused renderer tests for `NewThreadPage` onboarding and `ModelProviderSettings`. +- Run the repository-required `pnpm run format`, `pnpm run i18n`, and `pnpm run lint` checks. diff --git a/docs/issues/guided-onboarding-first-chat-confirm/spec.md b/docs/issues/guided-onboarding-first-chat-confirm/spec.md new file mode 100644 index 000000000..59c731a1c --- /dev/null +++ b/docs/issues/guided-onboarding-first-chat-confirm/spec.md @@ -0,0 +1,21 @@ +# Guided Onboarding First Chat Confirm + +## Problem + +During the guided `first-chat` step, clicking the confirm action can appear to do nothing because the handler only focuses the raw contenteditable element instead of invoking the chat input's editor-aware focus path. + +## User Story + +As a user on the final onboarding step, I want the confirm action to move focus into the real chat editor so I can immediately start typing my first message. + +## Acceptance Criteria + +- When the guided `first-chat` overlay is visible, clicking the primary confirm action focuses the real chat input editor. +- The guide remains active until the first successful message send completes the onboarding step. +- A focused renderer test covers the confirm action calling the chat input focus path. + +## Non-goals + +- No change to onboarding completion semantics for the first-chat step. +- No change to chat send behavior. +- No change to the final guide copy. diff --git a/docs/issues/guided-onboarding-first-chat-confirm/tasks.md b/docs/issues/guided-onboarding-first-chat-confirm/tasks.md new file mode 100644 index 000000000..a8fea6f5c --- /dev/null +++ b/docs/issues/guided-onboarding-first-chat-confirm/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Add SDD artifacts. +- [x] Expose an editor-aware chat input focus method. +- [x] Route the `first-chat` confirm action through the exposed focus method. +- [x] Add focused regression coverage for the confirm action. +- [x] Run format, i18n, lint, and focused tests. diff --git a/docs/issues/pr-1621-ai-review-fixes/plan.md b/docs/issues/pr-1621-ai-review-fixes/plan.md new file mode 100644 index 000000000..cd5581e5e --- /dev/null +++ b/docs/issues/pr-1621-ai-review-fixes/plan.md @@ -0,0 +1,15 @@ +# Implementation Plan + +## Change + +- Update shared onboarding step resolution fallback ordering. +- Add guarded step selection when `startGuidedOnboarding` receives a terminal requested step. +- Tighten renderer onboarding step finalization condition for nullish `currentStepId`. +- Sync provider settings tab selection from onboarding step in one shared helper used by both watchers. +- Add runtime response parsing in onboarding client using existing route schema. +- Update onboarding locale strings for the reviewed non-English locale files. +- Extend tests for onboarding route behavior and onboarding client response validation. + +## Validation + +- Run format, i18n, lint, and focused onboarding-related tests for touched modules. diff --git a/docs/issues/pr-1621-ai-review-fixes/spec.md b/docs/issues/pr-1621-ai-review-fixes/spec.md new file mode 100644 index 000000000..ce75e05f8 --- /dev/null +++ b/docs/issues/pr-1621-ai-review-fixes/spec.md @@ -0,0 +1,19 @@ +# PR #1621 AI Review Fixes + +## Problem + +AI review on PR #1621 reported onboarding flow edge cases and missing onboarding localization in several locale files. These issues can leave onboarding on an invalid step, hide the intended settings tab during onboarding, or show untranslated onboarding copy. + +## Acceptance Criteria + +- Resuming current onboarding step prefers an `in_progress` step before `pending` when `currentStepId` is empty. +- Starting onboarding with a requested completed/skipped step falls back to the next pending step instead of pinning `currentStepId` to a terminal step. +- Guided onboarding finalization treats both `null` and `undefined` `currentStepId` as no active step. +- Provider settings tab selection remains aligned with onboarding steps during provider changes. +- Onboarding renderer client validates bridge responses and throws clear errors when response shape is invalid. +- Reviewed locale files in this scope no longer show English onboarding copy. + +## Non-goals + +- No unrelated onboarding UX redesign. +- No broad localization rewrite beyond the reviewed onboarding strings. diff --git a/docs/issues/pr-1621-ai-review-fixes/tasks.md b/docs/issues/pr-1621-ai-review-fixes/tasks.md new file mode 100644 index 000000000..32b194451 --- /dev/null +++ b/docs/issues/pr-1621-ai-review-fixes/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [ ] Add SDD issue artifacts. +- [ ] Fix onboarding logic edge cases (shared/main/renderer). +- [ ] Harden onboarding client bridge response handling. +- [ ] Update reviewed onboarding locale translations. +- [ ] Add/update tests for new behavior. +- [ ] Run validation commands. diff --git a/docs/issues/prcheck-format-onboarding/plan.md b/docs/issues/prcheck-format-onboarding/plan.md new file mode 100644 index 000000000..f7992d429 --- /dev/null +++ b/docs/issues/prcheck-format-onboarding/plan.md @@ -0,0 +1,16 @@ +# Plan + +## Diagnosis + +The local PR Check reproduction fails at `pnpm run format:check` for `src/main/routes/onboarding/onboardingRouteSupport.ts`. + +## Approach + +Run the repository formatter on the reported file, inspect the resulting diff, then rerun PR Check steps to confirm the failure is resolved. + +## Test Strategy + +- `pnpm run format:check` +- `pnpm run i18n` +- `pnpm run lint` +- Continue to `pnpm run build` if earlier checks pass. diff --git a/docs/issues/prcheck-format-onboarding/spec.md b/docs/issues/prcheck-format-onboarding/spec.md new file mode 100644 index 000000000..f0b3affa6 --- /dev/null +++ b/docs/issues/prcheck-format-onboarding/spec.md @@ -0,0 +1,21 @@ +# PR Check Format Failure + +## User Story + +As a contributor, I want the PR Check workflow to pass for onboarding route changes so review is not blocked by formatting drift. + +## Acceptance Criteria + +- `pnpm run format:check` passes locally. +- The workflow-equivalent checks continue past formatting without introducing behavior changes. +- The fix does not alter guided onboarding state semantics. + +## Non-Goals + +- No onboarding UX or storage behavior changes. +- No CI workflow changes unless the reproduced failure requires them. + +## Constraints + +- Keep the change minimal and compatible with existing formatter rules. +- Preserve existing user work in the branch. diff --git a/docs/issues/prcheck-format-onboarding/tasks.md b/docs/issues/prcheck-format-onboarding/tasks.md new file mode 100644 index 000000000..166689a75 --- /dev/null +++ b/docs/issues/prcheck-format-onboarding/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Reproduce the PR Check failure locally. +- [x] Apply the minimal formatting fix. +- [x] Rerun relevant PR Check commands. +- [x] Summarize the result and any remaining risks. diff --git a/src/main/events.ts b/src/main/events.ts index 8727db4ae..898923cc8 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -128,6 +128,10 @@ export const SETTINGS_EVENTS = { PROVIDER_INSTALL: 'settings:provider-install' } +export const DEV_EVENTS = { + START_GUIDED_ONBOARDING: 'dev:start-guided-onboarding' +} + // ollama 相关事件 export const OLLAMA_EVENTS = { PULL_MODEL_PROGRESS: 'ollama:pull-model-progress' diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 6e09fe4e9..5a4afc478 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -1389,6 +1389,25 @@ export class WindowPresenter implements IWindowPresenter { return null } + public focusMainWindow(): boolean { + if (this.mainWindowId == null) { + return false + } + + const mainWindow = BrowserWindow.fromId(this.mainWindowId) + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { + return false + } + + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + mainWindow.show() + mainWindow.focus() + return true + } + public setPendingSettingsProviderInstall(preview: ProviderInstallPreview): void { this.pendingSettingsProviderInstalls.push(this.clonePendingSettingsProviderInstall(preview)) } diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index c61c5793d..d53a14854 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -89,6 +89,11 @@ import { mcpSubmitSamplingDecisionRoute, mcpUpdateServerRoute, modelsGetProviderCatalogRoute, + onboardingCompleteRoute, + onboardingGetStateRoute, + onboardingResetRoute, + onboardingSetStepStatusRoute, + onboardingStartRoute, pluginsDisableRoute, pluginsEnableRoute, pluginsGetRoute, @@ -216,6 +221,13 @@ import { ChatService } from './chat/chatService' import { dispatchConfigRoute } from './config/configRouteHandler' import { createPresenterHotPathPorts } from './hotPathPorts' import { dispatchModelRoute } from './models/modelRouteHandler' +import { + completeGuidedOnboarding, + readGuidedOnboardingState, + resetGuidedOnboarding, + setGuidedOnboardingStepStatus, + startGuidedOnboarding +} from './onboarding/onboardingRouteSupport' import { dispatchProviderRoute } from './providers/providerRouteHandler' import { createNodeScheduler } from './scheduler' import { ProviderService } from './providers/providerService' @@ -1409,6 +1421,38 @@ export async function dispatchDeepchatRoute( return settingsActivityListRoute.output.parse({ activities }) } + case onboardingGetStateRoute.name: { + onboardingGetStateRoute.input.parse(rawInput) + const state = readGuidedOnboardingState(runtime.configPresenter) + return onboardingGetStateRoute.output.parse({ state }) + } + + case onboardingStartRoute.name: { + const input = onboardingStartRoute.input.parse(rawInput) + const state = startGuidedOnboarding(runtime.configPresenter, input) + return onboardingStartRoute.output.parse({ state }) + } + + case onboardingSetStepStatusRoute.name: { + const input = onboardingSetStepStatusRoute.input.parse(rawInput) + const state = setGuidedOnboardingStepStatus(runtime.configPresenter, input) + return onboardingSetStepStatusRoute.output.parse({ state }) + } + + case onboardingCompleteRoute.name: { + const input = onboardingCompleteRoute.input.parse(rawInput) + const state = completeGuidedOnboarding(runtime.configPresenter, Date.now(), { + force: input.force + }) + return onboardingCompleteRoute.output.parse({ state }) + } + + case onboardingResetRoute.name: { + onboardingResetRoute.input.parse(rawInput) + const state = resetGuidedOnboarding(runtime.configPresenter) + return onboardingResetRoute.output.parse({ state }) + } + case startupGetBootstrapRoute.name: { startupGetBootstrapRoute.input.parse(rawInput) const coordinator = (runtime as Partial).startupWorkloadCoordinator diff --git a/src/main/routes/onboarding/onboardingRouteSupport.ts b/src/main/routes/onboarding/onboardingRouteSupport.ts new file mode 100644 index 000000000..313e23e86 --- /dev/null +++ b/src/main/routes/onboarding/onboardingRouteSupport.ts @@ -0,0 +1,984 @@ +import type { IConfigPresenter } from '@shared/presenter' +import type { + GuidedOnboardingState, + GuidedOnboardingStepId, + GuidedOnboardingStepState +} from '@shared/contracts/routes' +import { + guidedOnboardingStateSchema, + guidedOnboardingStepIds, + guidedOnboardingVersion +} from '@shared/contracts/routes' +import { + GUIDED_ONBOARDING_STEP_IDS as SHARED_GUIDED_ONBOARDING_STEP_IDS, + LEGACY_GUIDED_ONBOARDING_STEP_IDS, + isLegacyGuidedOnboardingStepId, + isGuidedOnboardingRequiredStepId, + type LegacyGuidedOnboardingStepId +} from '@shared/guidedOnboarding' + +export const GUIDED_ONBOARDING_STATE_KEY = 'guidedOnboardingState' + +export const GUIDED_ONBOARDING_STEP_IDS: GuidedOnboardingStepId[] = [...guidedOnboardingStepIds] + +type LegacyGuidedOnboardingStepState = Omit & { + id: LegacyGuidedOnboardingStepId +} + +type PreviousGuidedOnboardingStepId = + | 'open-settings' + | 'select-provider' + | 'provider-api-key' + | 'mcp' + | 'skills' + | 'switch-agent' + | 'switch-model' + | 'first-chat' + +type PreviousGuidedOnboardingStepState = Omit & { + id: PreviousGuidedOnboardingStepId +} + +type Version3GuidedOnboardingStepId = + | 'select-provider' + | 'provider-api-key' + | 'mcp' + | 'skills' + | 'switch-agent' + | 'switch-model' + | 'first-chat' + +type Version3GuidedOnboardingStepState = Omit & { + id: Version3GuidedOnboardingStepId +} + +type StoredGuidedOnboardingStateCandidate = Omit< + Partial, + 'version' | 'currentStepId' | 'steps' +> & { + version?: number + currentStepId?: unknown + steps?: unknown +} + +type ConfigPresenterPort = Pick + +const createDefaultStepState = (id: GuidedOnboardingStepId): GuidedOnboardingStepState => ({ + id, + required: isGuidedOnboardingRequiredStepId(id), + status: 'pending', + startedAt: null, + completedAt: null, + skippedAt: null +}) + +const createLegacyDefaultStepState = ( + id: LegacyGuidedOnboardingStepId +): LegacyGuidedOnboardingStepState => ({ + id, + status: 'pending', + startedAt: null, + completedAt: null, + skippedAt: null +}) + +const PREVIOUS_GUIDED_ONBOARDING_STEP_IDS = [ + 'open-settings', + 'select-provider', + 'provider-api-key', + 'mcp', + 'skills', + 'switch-agent', + 'switch-model', + 'first-chat' +] as const satisfies readonly PreviousGuidedOnboardingStepId[] + +const VERSION3_GUIDED_ONBOARDING_STEP_IDS = [ + 'select-provider', + 'provider-api-key', + 'mcp', + 'skills', + 'switch-agent', + 'switch-model', + 'first-chat' +] as const satisfies readonly Version3GuidedOnboardingStepId[] + +const isPreviousGuidedOnboardingStepId = ( + value: unknown +): value is PreviousGuidedOnboardingStepId => + typeof value === 'string' && + PREVIOUS_GUIDED_ONBOARDING_STEP_IDS.includes(value as PreviousGuidedOnboardingStepId) + +const isVersion3GuidedOnboardingStepId = ( + value: unknown +): value is Version3GuidedOnboardingStepId => + typeof value === 'string' && + VERSION3_GUIDED_ONBOARDING_STEP_IDS.includes(value as Version3GuidedOnboardingStepId) + +const createDefaultState = (now: number): GuidedOnboardingState => ({ + version: guidedOnboardingVersion, + status: 'idle', + startedAt: null, + completedAt: null, + lastActiveAt: now, + currentStepId: null, + steps: GUIDED_ONBOARDING_STEP_IDS.map((id) => createDefaultStepState(id)) +}) + +const resolveStepMap = ( + stepIds: readonly TStepId[], + storedSteps: unknown +): Map> => { + if (!Array.isArray(storedSteps)) { + return new Map() + } + + return storedSteps.reduce>>((acc, candidate) => { + if (!candidate || typeof candidate !== 'object') { + return acc + } + + const stepId = (candidate as { id?: unknown }).id + if (!stepIds.includes(stepId as TStepId)) { + return acc + } + + acc.set(stepId as TStepId, candidate as Partial) + return acc + }, new Map()) +} + +const normalizeStepState = ( + stepId: GuidedOnboardingStepId, + stored: Partial | undefined +): GuidedOnboardingStepState => { + const fallback = createDefaultStepState(stepId) + const status = + stored?.status === 'pending' || + stored?.status === 'in_progress' || + stored?.status === 'completed' || + stored?.status === 'skipped' + ? stored.status + : fallback.status + + const timestampOrNull = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null + + return { + id: stepId, + required: isGuidedOnboardingRequiredStepId(stepId), + status, + startedAt: timestampOrNull(stored?.startedAt), + completedAt: timestampOrNull(stored?.completedAt), + skippedAt: timestampOrNull(stored?.skippedAt) + } +} + +const normalizeLegacyStepState = ( + stepId: LegacyGuidedOnboardingStepId, + stored: Partial | undefined +): LegacyGuidedOnboardingStepState => { + const fallback = createLegacyDefaultStepState(stepId) + const status = + stored?.status === 'pending' || + stored?.status === 'in_progress' || + stored?.status === 'completed' || + stored?.status === 'skipped' + ? stored.status + : fallback.status + + const timestampOrNull = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null + + return { + id: stepId, + status, + startedAt: timestampOrNull(stored?.startedAt), + completedAt: timestampOrNull(stored?.completedAt), + skippedAt: timestampOrNull(stored?.skippedAt) + } +} + +const normalizePreviousStepState = ( + stepId: PreviousGuidedOnboardingStepId, + stored: Partial | undefined +): PreviousGuidedOnboardingStepState => { + const fallback = createDefaultStepState(stepId === 'open-settings' ? 'select-provider' : stepId) + const status = + stored?.status === 'pending' || + stored?.status === 'in_progress' || + stored?.status === 'completed' || + stored?.status === 'skipped' + ? stored.status + : fallback.status + + const timestampOrNull = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null + + return { + id: stepId, + status, + startedAt: timestampOrNull(stored?.startedAt), + completedAt: timestampOrNull(stored?.completedAt), + skippedAt: timestampOrNull(stored?.skippedAt) + } +} + +const normalizeVersion3StepState = ( + stepId: Version3GuidedOnboardingStepId, + stored: Partial | undefined +): Version3GuidedOnboardingStepState => { + const fallback = createDefaultStepState(stepId) + const status = + stored?.status === 'pending' || + stored?.status === 'in_progress' || + stored?.status === 'completed' || + stored?.status === 'skipped' + ? stored.status + : fallback.status + + const timestampOrNull = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null + + return { + id: stepId, + status, + startedAt: timestampOrNull(stored?.startedAt), + completedAt: timestampOrNull(stored?.completedAt), + skippedAt: timestampOrNull(stored?.skippedAt) + } +} + +const cloneStepStatus = ( + step: Pick, + status: GuidedOnboardingStepState['status'] +): Partial => ({ + status, + startedAt: step.startedAt, + completedAt: status === 'completed' ? (step.completedAt ?? step.startedAt) : null, + skippedAt: status === 'skipped' ? (step.skippedAt ?? step.completedAt ?? step.startedAt) : null +}) + +const setStepState = ( + stepMap: Map, + stepId: GuidedOnboardingStepId, + overrides: Partial +) => { + stepMap.set(stepId, { + ...(stepMap.get(stepId) ?? createDefaultStepState(stepId)), + ...overrides + }) +} + +const migratePreviousState = ( + candidate: StoredGuidedOnboardingStateCandidate, + now: number +): GuidedOnboardingState => { + const fallback = createDefaultState(now) + const previousStepMap = resolveStepMap(PREVIOUS_GUIDED_ONBOARDING_STEP_IDS, candidate.steps) + const previousSteps = { + 'open-settings': normalizePreviousStepState( + 'open-settings', + previousStepMap.get('open-settings') + ), + 'select-provider': normalizePreviousStepState( + 'select-provider', + previousStepMap.get('select-provider') + ), + 'provider-api-key': normalizePreviousStepState( + 'provider-api-key', + previousStepMap.get('provider-api-key') + ), + mcp: normalizePreviousStepState('mcp', previousStepMap.get('mcp')), + skills: normalizePreviousStepState('skills', previousStepMap.get('skills')), + 'switch-agent': normalizePreviousStepState('switch-agent', previousStepMap.get('switch-agent')), + 'switch-model': normalizePreviousStepState('switch-model', previousStepMap.get('switch-model')), + 'first-chat': normalizePreviousStepState('first-chat', previousStepMap.get('first-chat')) + } + const status = + candidate.status === 'active' || candidate.status === 'completed' || candidate.status === 'idle' + ? candidate.status + : fallback.status + const previousCurrentStepId = isPreviousGuidedOnboardingStepId(candidate.currentStepId) + ? candidate.currentStepId + : null + const steps = new Map( + GUIDED_ONBOARDING_STEP_IDS.map((stepId) => [stepId, createDefaultStepState(stepId)]) + ) + const laterProgressExists = [ + previousSteps['provider-api-key'], + previousSteps.mcp, + previousSteps.skills, + previousSteps['switch-agent'], + previousSteps['switch-model'], + previousSteps['first-chat'] + ].some((step) => step.status !== 'pending') + const progressedPastProviderModel = + status === 'completed' || + previousCurrentStepId === 'mcp' || + previousCurrentStepId === 'skills' || + previousCurrentStepId === 'switch-agent' || + previousCurrentStepId === 'switch-model' || + previousCurrentStepId === 'first-chat' || + previousSteps.mcp.status !== 'pending' || + previousSteps.skills.status !== 'pending' || + previousSteps['switch-agent'].status !== 'pending' || + previousSteps['switch-model'].status !== 'pending' || + previousSteps['first-chat'].status !== 'pending' + + if (previousSteps['select-provider'].status !== 'pending') { + setStepState( + steps, + 'select-provider', + cloneStepStatus(previousSteps['select-provider'], previousSteps['select-provider'].status) + ) + } else if (laterProgressExists) { + setStepState(steps, 'select-provider', { + status: 'completed', + startedAt: + previousSteps['select-provider'].startedAt ?? + previousSteps['open-settings'].startedAt ?? + candidate.startedAt ?? + now, + completedAt: + previousSteps['open-settings'].completedAt ?? + previousSteps['select-provider'].completedAt ?? + previousSteps['select-provider'].startedAt ?? + previousSteps['open-settings'].startedAt ?? + candidate.startedAt ?? + now, + skippedAt: null + }) + } else if ( + previousCurrentStepId === 'open-settings' || + previousCurrentStepId === 'select-provider' || + previousSteps['open-settings'].status === 'completed' || + previousSteps['open-settings'].status === 'in_progress' + ) { + setStepState(steps, 'select-provider', { + status: 'in_progress', + startedAt: + previousSteps['select-provider'].startedAt ?? + previousSteps['open-settings'].startedAt ?? + previousSteps['open-settings'].completedAt ?? + candidate.startedAt ?? + now, + completedAt: null, + skippedAt: null + }) + } + + if (progressedPastProviderModel) { + setStepState(steps, 'provider-model', { + status: 'completed', + startedAt: + previousSteps['provider-api-key'].completedAt ?? + previousSteps['provider-api-key'].startedAt ?? + previousSteps['select-provider'].completedAt ?? + candidate.startedAt ?? + now, + completedAt: + previousSteps['provider-api-key'].completedAt ?? + previousSteps['provider-api-key'].skippedAt ?? + previousSteps['provider-api-key'].startedAt ?? + previousSteps['select-provider'].completedAt ?? + candidate.startedAt ?? + now, + skippedAt: null + }) + } + + for (const stepId of [ + 'provider-api-key', + 'mcp', + 'skills', + 'switch-agent', + 'switch-model', + 'first-chat' + ] as const) { + const step = previousSteps[stepId] + if (step.status === 'pending') { + continue + } + + setStepState(steps, stepId, { + status: step.status, + startedAt: step.startedAt, + completedAt: step.completedAt, + skippedAt: step.skippedAt + }) + } + + const nextStateBase: GuidedOnboardingState = { + version: guidedOnboardingVersion, + status, + startedAt: + typeof candidate.startedAt === 'number' && + Number.isFinite(candidate.startedAt) && + candidate.startedAt >= 0 + ? candidate.startedAt + : null, + completedAt: + typeof candidate.completedAt === 'number' && + Number.isFinite(candidate.completedAt) && + candidate.completedAt >= 0 + ? candidate.completedAt + : null, + lastActiveAt: + typeof candidate.lastActiveAt === 'number' && + Number.isFinite(candidate.lastActiveAt) && + candidate.lastActiveAt >= 0 + ? candidate.lastActiveAt + : now, + currentStepId: null, + steps: GUIDED_ONBOARDING_STEP_IDS.map( + (stepId) => steps.get(stepId) ?? createDefaultStepState(stepId) + ) + } + + const currentStepId = + status === 'completed' + ? null + : previousCurrentStepId === 'open-settings' + ? 'select-provider' + : previousCurrentStepId && GUIDED_ONBOARDING_STEP_IDS.includes(previousCurrentStepId) + ? previousCurrentStepId + : findNextPendingStepId(nextStateBase) + + return guidedOnboardingStateSchema.parse({ + ...nextStateBase, + currentStepId + }) +} + +const migrateVersion3State = ( + candidate: StoredGuidedOnboardingStateCandidate, + now: number +): GuidedOnboardingState => { + const fallback = createDefaultState(now) + const version3StepMap = resolveStepMap(VERSION3_GUIDED_ONBOARDING_STEP_IDS, candidate.steps) + const version3Steps = { + 'select-provider': normalizeVersion3StepState( + 'select-provider', + version3StepMap.get('select-provider') + ), + 'provider-api-key': normalizeVersion3StepState( + 'provider-api-key', + version3StepMap.get('provider-api-key') + ), + mcp: normalizeVersion3StepState('mcp', version3StepMap.get('mcp')), + skills: normalizeVersion3StepState('skills', version3StepMap.get('skills')), + 'switch-agent': normalizeVersion3StepState('switch-agent', version3StepMap.get('switch-agent')), + 'switch-model': normalizeVersion3StepState('switch-model', version3StepMap.get('switch-model')), + 'first-chat': normalizeVersion3StepState('first-chat', version3StepMap.get('first-chat')) + } + const status = + candidate.status === 'active' || candidate.status === 'completed' || candidate.status === 'idle' + ? candidate.status + : fallback.status + const version3CurrentStepId = isVersion3GuidedOnboardingStepId(candidate.currentStepId) + ? candidate.currentStepId + : null + const steps = new Map( + GUIDED_ONBOARDING_STEP_IDS.map((stepId) => [stepId, createDefaultStepState(stepId)]) + ) + const progressedPastProviderModel = + status === 'completed' || + version3CurrentStepId === 'mcp' || + version3CurrentStepId === 'skills' || + version3CurrentStepId === 'switch-agent' || + version3CurrentStepId === 'switch-model' || + version3CurrentStepId === 'first-chat' || + version3Steps.mcp.status !== 'pending' || + version3Steps.skills.status !== 'pending' || + version3Steps['switch-agent'].status !== 'pending' || + version3Steps['switch-model'].status !== 'pending' || + version3Steps['first-chat'].status !== 'pending' + + for (const stepId of VERSION3_GUIDED_ONBOARDING_STEP_IDS) { + const step = version3Steps[stepId] + if (step.status === 'pending') { + continue + } + + setStepState(steps, stepId, { + status: step.status, + startedAt: step.startedAt, + completedAt: step.completedAt, + skippedAt: step.skippedAt + }) + } + + if (progressedPastProviderModel) { + setStepState(steps, 'provider-model', { + status: 'completed', + startedAt: + version3Steps['provider-api-key'].completedAt ?? + version3Steps['provider-api-key'].skippedAt ?? + version3Steps['provider-api-key'].startedAt ?? + version3Steps['select-provider'].completedAt ?? + candidate.startedAt ?? + now, + completedAt: + version3Steps['provider-api-key'].completedAt ?? + version3Steps['provider-api-key'].skippedAt ?? + version3Steps['provider-api-key'].startedAt ?? + version3Steps['select-provider'].completedAt ?? + candidate.startedAt ?? + now, + skippedAt: null + }) + } + + const nextStateBase: GuidedOnboardingState = { + version: guidedOnboardingVersion, + status, + startedAt: + typeof candidate.startedAt === 'number' && + Number.isFinite(candidate.startedAt) && + candidate.startedAt >= 0 + ? candidate.startedAt + : null, + completedAt: + typeof candidate.completedAt === 'number' && + Number.isFinite(candidate.completedAt) && + candidate.completedAt >= 0 + ? candidate.completedAt + : null, + lastActiveAt: + typeof candidate.lastActiveAt === 'number' && + Number.isFinite(candidate.lastActiveAt) && + candidate.lastActiveAt >= 0 + ? candidate.lastActiveAt + : now, + currentStepId: null, + steps: GUIDED_ONBOARDING_STEP_IDS.map( + (stepId) => steps.get(stepId) ?? createDefaultStepState(stepId) + ) + } + + const currentStepId = + status === 'completed' + ? null + : version3CurrentStepId && GUIDED_ONBOARDING_STEP_IDS.includes(version3CurrentStepId) + ? version3CurrentStepId + : findNextPendingStepId(nextStateBase) + + return guidedOnboardingStateSchema.parse({ + ...nextStateBase, + currentStepId + }) +} + +const migrateLegacyState = ( + candidate: StoredGuidedOnboardingStateCandidate, + now: number +): GuidedOnboardingState => { + const fallback = createDefaultState(now) + const legacyStepMap = resolveStepMap(LEGACY_GUIDED_ONBOARDING_STEP_IDS, candidate.steps) + const legacySteps = { + provider: normalizeLegacyStepState('provider', legacyStepMap.get('provider')), + mcp: normalizeLegacyStepState('mcp', legacyStepMap.get('mcp')), + skills: normalizeLegacyStepState('skills', legacyStepMap.get('skills')), + plugins: normalizeLegacyStepState('plugins', legacyStepMap.get('plugins')), + 'switch-model': normalizeLegacyStepState('switch-model', legacyStepMap.get('switch-model')), + 'first-chat': normalizeLegacyStepState('first-chat', legacyStepMap.get('first-chat')) + } + const status = + candidate.status === 'active' || candidate.status === 'completed' || candidate.status === 'idle' + ? candidate.status + : fallback.status + const legacyCurrentStepId = isLegacyGuidedOnboardingStepId(candidate.currentStepId) + ? candidate.currentStepId + : null + const steps = new Map( + SHARED_GUIDED_ONBOARDING_STEP_IDS.map((stepId) => [stepId, createDefaultStepState(stepId)]) + ) + const providerStartedAt = legacySteps.provider.startedAt ?? candidate.startedAt ?? now + const switchStartedAt = legacySteps['switch-model'].startedAt ?? candidate.startedAt ?? now + + if ( + status === 'completed' || + legacySteps.provider.status === 'completed' || + legacyCurrentStepId === 'mcp' || + legacyCurrentStepId === 'skills' || + legacyCurrentStepId === 'plugins' || + legacyCurrentStepId === 'switch-model' || + legacyCurrentStepId === 'first-chat' || + legacySteps.mcp.status !== 'pending' || + legacySteps.skills.status !== 'pending' || + legacySteps.plugins.status !== 'pending' || + legacySteps['switch-model'].status !== 'pending' || + legacySteps['first-chat'].status !== 'pending' + ) { + setStepState(steps, 'select-provider', cloneStepStatus(legacySteps.provider, 'completed')) + setStepState(steps, 'provider-api-key', cloneStepStatus(legacySteps.provider, 'completed')) + setStepState(steps, 'provider-model', cloneStepStatus(legacySteps.provider, 'completed')) + } else if (legacyCurrentStepId === 'provider' || legacySteps.provider.status === 'in_progress') { + setStepState(steps, 'select-provider', { + status: 'in_progress', + startedAt: providerStartedAt, + completedAt: null, + skippedAt: null + }) + } + + for (const stepId of ['mcp', 'skills'] as const) { + const legacyStep = legacySteps[stepId] + if (legacyStep.status !== 'pending') { + setStepState(steps, stepId, cloneStepStatus(legacyStep, legacyStep.status)) + } + } + + if ( + status === 'completed' || + legacySteps['switch-model'].status === 'completed' || + legacyCurrentStepId === 'first-chat' || + legacySteps['first-chat'].status !== 'pending' + ) { + setStepState(steps, 'switch-agent', cloneStepStatus(legacySteps['switch-model'], 'completed')) + setStepState(steps, 'switch-model', cloneStepStatus(legacySteps['switch-model'], 'completed')) + } else if ( + legacyCurrentStepId === 'switch-model' || + legacyCurrentStepId === 'plugins' || + legacySteps['switch-model'].status === 'in_progress' + ) { + setStepState(steps, 'switch-agent', { + status: 'in_progress', + startedAt: switchStartedAt, + completedAt: null, + skippedAt: null + }) + } + + if (legacySteps['first-chat'].status !== 'pending') { + setStepState( + steps, + 'first-chat', + cloneStepStatus(legacySteps['first-chat'], legacySteps['first-chat'].status) + ) + } + + const nextStateBase: GuidedOnboardingState = { + version: guidedOnboardingVersion, + status, + startedAt: + typeof candidate.startedAt === 'number' && + Number.isFinite(candidate.startedAt) && + candidate.startedAt >= 0 + ? candidate.startedAt + : null, + completedAt: + typeof candidate.completedAt === 'number' && + Number.isFinite(candidate.completedAt) && + candidate.completedAt >= 0 + ? candidate.completedAt + : null, + lastActiveAt: + typeof candidate.lastActiveAt === 'number' && + Number.isFinite(candidate.lastActiveAt) && + candidate.lastActiveAt >= 0 + ? candidate.lastActiveAt + : now, + currentStepId: null, + steps: SHARED_GUIDED_ONBOARDING_STEP_IDS.map( + (stepId) => steps.get(stepId) ?? createDefaultStepState(stepId) + ) + } + + const currentStepId = + status === 'completed' + ? null + : legacyCurrentStepId === 'provider' + ? 'select-provider' + : legacyCurrentStepId === 'mcp' + ? 'mcp' + : legacyCurrentStepId === 'skills' + ? 'skills' + : legacyCurrentStepId === 'plugins' || legacyCurrentStepId === 'switch-model' + ? 'switch-agent' + : legacyCurrentStepId === 'first-chat' + ? 'first-chat' + : findNextPendingStepId(nextStateBase) + + return guidedOnboardingStateSchema.parse({ + ...nextStateBase, + currentStepId + }) +} + +const normalizeState = (raw: unknown, now: number): GuidedOnboardingState => { + const fallback = createDefaultState(now) + + if (!raw || typeof raw !== 'object') { + return fallback + } + + const candidate = raw as StoredGuidedOnboardingStateCandidate + + if (candidate.version === 2) { + return migratePreviousState(candidate, now) + } + + if (candidate.version === 3) { + return migrateVersion3State(candidate, now) + } + + if (candidate.version !== guidedOnboardingVersion) { + return migrateLegacyState(candidate, now) + } + + const stepMap = resolveStepMap(SHARED_GUIDED_ONBOARDING_STEP_IDS, candidate.steps) + const steps = SHARED_GUIDED_ONBOARDING_STEP_IDS.map((stepId) => + normalizeStepState(stepId, stepMap.get(stepId)) + ) + + const currentStepId = + typeof candidate.currentStepId === 'string' && + SHARED_GUIDED_ONBOARDING_STEP_IDS.includes(candidate.currentStepId as GuidedOnboardingStepId) + ? (candidate.currentStepId as GuidedOnboardingStepId) + : null + + const normalized: GuidedOnboardingState = { + version: guidedOnboardingVersion, + status: + candidate.status === 'active' || + candidate.status === 'completed' || + candidate.status === 'idle' + ? candidate.status + : fallback.status, + startedAt: + typeof candidate.startedAt === 'number' && + Number.isFinite(candidate.startedAt) && + candidate.startedAt >= 0 + ? candidate.startedAt + : null, + completedAt: + typeof candidate.completedAt === 'number' && + Number.isFinite(candidate.completedAt) && + candidate.completedAt >= 0 + ? candidate.completedAt + : null, + lastActiveAt: + typeof candidate.lastActiveAt === 'number' && + Number.isFinite(candidate.lastActiveAt) && + candidate.lastActiveAt >= 0 + ? candidate.lastActiveAt + : now, + currentStepId, + steps + } + + return guidedOnboardingStateSchema.parse(normalized) +} + +const findNextPendingStepId = (state: GuidedOnboardingState): GuidedOnboardingStepId | null => + state.steps.find((step) => step.status === 'pending')?.id ?? null + +const persistState = ( + configPresenter: ConfigPresenterPort, + state: GuidedOnboardingState +): GuidedOnboardingState => { + configPresenter.setSetting(GUIDED_ONBOARDING_STATE_KEY, state) + return state +} + +export function readGuidedOnboardingState( + configPresenter: ConfigPresenterPort, + now = Date.now() +): GuidedOnboardingState { + const stored = configPresenter.getSetting(GUIDED_ONBOARDING_STATE_KEY) + return normalizeState(stored, now) +} + +export function startGuidedOnboarding( + configPresenter: ConfigPresenterPort, + options: { + force?: boolean + stepId?: GuidedOnboardingStepId + } = {}, + now = Date.now() +): GuidedOnboardingState { + const existing = readGuidedOnboardingState(configPresenter, now) + + if (existing.status === 'completed' && !options.force) { + return existing + } + + const baseState = options.force ? createDefaultState(now) : existing + const requestedStepId = + options.stepId && SHARED_GUIDED_ONBOARDING_STEP_IDS.includes(options.stepId) + ? options.stepId + : undefined + const candidateStepId = + requestedStepId ?? + baseState.currentStepId ?? + baseState.steps.find((step) => step.status === 'in_progress')?.id ?? + findNextPendingStepId(baseState) + const candidateStep = candidateStepId + ? baseState.steps.find((step) => step.id === candidateStepId) + : undefined + const nextStepId = + candidateStep && (candidateStep.status === 'completed' || candidateStep.status === 'skipped') + ? findNextPendingStepId(baseState) + : candidateStepId + + const steps: GuidedOnboardingStepState[] = baseState.steps.map((step) => { + if (step.id !== nextStepId) { + return step + } + + return { + ...step, + status: + step.status === 'completed' || step.status === 'skipped' + ? step.status + : ('in_progress' as const), + startedAt: step.startedAt ?? now, + completedAt: step.status === 'completed' ? step.completedAt : null, + skippedAt: step.status === 'skipped' ? step.skippedAt : null + } + }) + + return persistState(configPresenter, { + ...baseState, + status: 'active', + startedAt: baseState.startedAt ?? now, + completedAt: null, + lastActiveAt: now, + currentStepId: nextStepId ?? null, + steps + }) +} + +export function setGuidedOnboardingStepStatus( + configPresenter: ConfigPresenterPort, + input: { + stepId: GuidedOnboardingStepId + status: 'in_progress' | 'completed' | 'skipped' + }, + now = Date.now() +): GuidedOnboardingState { + const currentState = readGuidedOnboardingState(configPresenter, now) + const targetStep = currentState.steps.find((step) => step.id === input.stepId) + + if (!targetStep) { + throw new Error(`Unknown onboarding step: ${input.stepId}`) + } + + if (input.status === 'skipped' && targetStep.required) { + throw new Error(`Cannot skip required onboarding step: ${input.stepId}`) + } + + const nextSteps = currentState.steps.map((step) => { + if (step.id !== input.stepId) { + return step + } + + if (input.status === 'in_progress') { + return { + ...step, + status: 'in_progress' as const, + startedAt: step.startedAt ?? now, + completedAt: null, + skippedAt: null + } + } + + if (input.status === 'completed') { + return { + ...step, + status: 'completed' as const, + startedAt: step.startedAt ?? now, + completedAt: now, + skippedAt: null + } + } + + return { + ...step, + status: 'skipped' as const, + startedAt: step.startedAt, + completedAt: null, + skippedAt: now + } + }) + + const nextStateBase: GuidedOnboardingState = { + ...currentState, + status: 'active', + startedAt: currentState.startedAt ?? now, + completedAt: null, + lastActiveAt: now, + currentStepId: input.status === 'in_progress' ? input.stepId : null, + steps: nextSteps + } + + const nextStepId = + input.status === 'in_progress' ? input.stepId : findNextPendingStepId(nextStateBase) + + return persistState(configPresenter, { + ...nextStateBase, + currentStepId: nextStepId + }) +} + +export function completeGuidedOnboarding( + configPresenter: ConfigPresenterPort, + now = Date.now(), + options: { + force?: boolean + } = {} +): GuidedOnboardingState { + const currentState = readGuidedOnboardingState(configPresenter, now) + const incompleteRequiredStep = options.force + ? null + : currentState.steps.find((step) => step.required && step.status !== 'completed') + + if (incompleteRequiredStep) { + throw new Error(`Cannot complete onboarding before required step: ${incompleteRequiredStep.id}`) + } + + const finalizedSteps = currentState.steps.map((step) => { + if (step.status === 'completed' || step.status === 'skipped') { + return step + } + + if (options.force && step.required) { + return { + ...step, + status: 'completed' as const, + startedAt: step.startedAt ?? now, + completedAt: now, + skippedAt: null + } + } + + return { + ...step, + status: 'skipped' as const, + skippedAt: now, + completedAt: null + } + }) + + const nextState = persistState(configPresenter, { + ...currentState, + status: 'completed', + startedAt: currentState.startedAt ?? now, + completedAt: now, + lastActiveAt: now, + currentStepId: null, + steps: finalizedSteps + }) + + configPresenter.setSetting('init_complete', true) + return nextState +} + +export function resetGuidedOnboarding( + configPresenter: ConfigPresenterPort, + now = Date.now() +): GuidedOnboardingState { + return persistState(configPresenter, createDefaultState(now)) +} diff --git a/src/renderer/api/OnboardingClient.ts b/src/renderer/api/OnboardingClient.ts new file mode 100644 index 000000000..137c83fa7 --- /dev/null +++ b/src/renderer/api/OnboardingClient.ts @@ -0,0 +1,96 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { + guidedOnboardingStateSchema, + onboardingCompleteRoute, + onboardingGetStateRoute, + onboardingResetRoute, + onboardingSetStepStatusRoute, + onboardingStartRoute, + type GuidedOnboardingState, + type GuidedOnboardingStepId +} from '@shared/contracts/routes' +import { getDeepchatBridge } from './core' + +export function createOnboardingClient(bridge: DeepchatBridge = getDeepchatBridge()) { + const parseStateResponse = ( + routeName: string, + result: unknown + ): { + state: GuidedOnboardingState + } => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[OnboardingClient] Invalid response shape from ${routeName}`) + } + + const maybeState = (result as { state?: unknown }).state + const parsedState = guidedOnboardingStateSchema.safeParse(maybeState) + if (!parsedState.success) { + throw new Error(`[OnboardingClient] Invalid state response from ${routeName}`) + } + + return { state: parsedState.data } + } + + async function getState() { + try { + const result = await bridge.invoke(onboardingGetStateRoute.name, {}) + return parseStateResponse(onboardingGetStateRoute.name, result).state + } catch (error) { + console.error(`[OnboardingClient] ${onboardingGetStateRoute.name} failed:`, error) + throw error + } + } + + async function start(options: { force?: boolean; stepId?: GuidedOnboardingStepId } = {}) { + try { + const result = await bridge.invoke(onboardingStartRoute.name, options) + return parseStateResponse(onboardingStartRoute.name, result).state + } catch (error) { + console.error(`[OnboardingClient] ${onboardingStartRoute.name} failed:`, error) + throw error + } + } + + async function setStepStatus(input: { + stepId: GuidedOnboardingStepId + status: 'in_progress' | 'completed' | 'skipped' + }) { + try { + const result = await bridge.invoke(onboardingSetStepStatusRoute.name, input) + return parseStateResponse(onboardingSetStepStatusRoute.name, result).state + } catch (error) { + console.error(`[OnboardingClient] ${onboardingSetStepStatusRoute.name} failed:`, error) + throw error + } + } + + async function complete(input: { force?: boolean } = {}) { + try { + const result = await bridge.invoke(onboardingCompleteRoute.name, input) + return parseStateResponse(onboardingCompleteRoute.name, result).state + } catch (error) { + console.error(`[OnboardingClient] ${onboardingCompleteRoute.name} failed:`, error) + throw error + } + } + + async function reset() { + try { + const result = await bridge.invoke(onboardingResetRoute.name, {}) + return parseStateResponse(onboardingResetRoute.name, result).state + } catch (error) { + console.error(`[OnboardingClient] ${onboardingResetRoute.name} failed:`, error) + throw error + } + } + + return { + getState, + start, + setStepStatus, + complete, + reset + } +} + +export type OnboardingClient = ReturnType diff --git a/src/renderer/settings/components/AboutUsSettings.vue b/src/renderer/settings/components/AboutUsSettings.vue index 53fbeeabd..d0dd386c2 100644 --- a/src/renderer/settings/components/AboutUsSettings.vue +++ b/src/renderer/settings/components/AboutUsSettings.vue @@ -135,6 +135,16 @@ {{ t('about.clearMockUpdateButton') }} + +