mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
♻️ refactor(onboarding): extract language and privacy as shared prefix steps (#14538)
* ♻️ refactor(onboarding): extract language and privacy as shared prefix steps Move the language-selection and privacy/telemetry consent out of the classic flow into a shared prefix that runs at /onboarding before branching into either the agent or classic experience. Welcome decoration is merged with language selection on a single screen, dropping the total step count by one. Shared-prefix completion is derived from raw stored settings (s.settings.general.responseLanguage and telemetry), so no new schema fields are introduced and existing consumers that rely on the merged-default telemetry value are unaffected. Branch routing remains automatic (feature flag + isDesktop check) and is now encapsulated in deriveOnboardingBranchPath. Both branch routes guard against entering before the shared prefix is complete. MAX_ONBOARDING_STEPS drops from 5 to 3 (FullName, Interests, ProSettings). * ♻️ refactor(onboarding): use original Telemetry + ResponseLanguage as shared steps Revert the merged welcome+language design. The shared prefix now reuses the original two classic steps as-is: - Step 1: TelemetryStep (welcome decoration + privacy/telemetry consent) - Step 2: ResponseLanguageStep (language selection) Also suppress the mode-switch + skip footer on the bare /onboarding path so it only appears once the user has entered the agent or classic branch. * 🐛 fix(onboarding): persist shared-prefix step in URL to survive locale-triggered remounts Use react-router's useSearchParams to keep the active shared step in the URL (?step=2). Local useState was lost when switching language for the first time because i18next's first-time resource load triggers a remount up the tree; the URL param survives any remount. * 🐛 fix(onboarding): unblock branch redirect when user accepts default telemetry Derive commonStepsCompleted from responseLanguage alone. setSettings strips fields whose value matches DEFAULT_COMMON_SETTINGS, so accepting the default telemetry: true left s.settings.general.telemetry undefined and the derive selector never flipped to true — the redirect to the branch never fired. Step 2 (language) implies step 1 was completed because the flow is sequential, so checking responseLanguage alone is sufficient and robust against the default-strip behavior. * 🐛 fix(onboarding): redirect after step 2 by deriving completion from responseLanguage only setSettings strips fields that match defaultSettings, so writing telemetry=true (the default) never persists to s.settings.general. That made commonStepsCompleted permanently false even after the user finished both steps, blocking the redirect to the branch flow. Drop telemetry from the derive check. Step 1 completion is already tracked via the URL ?step=2 marker; step 2 completion is the only event that needs to flip commonStepsCompleted, signalled by writing responseLanguage (which always differs from the default since DEFAULT_COMMON_SETTINGS has no responseLanguage entry). * 🔨 chore(scripts): add reset-onboarding script for redoing the flow Takes an email, clears users.onboarding, agent_onboarding, full_name, interests and removes responseLanguage + telemetry from user_settings.general so the user re-enters the shared-prefix onboarding from step 1. Usage: pnpm workflow:reset-onboarding <email> bunx tsx scripts/resetOnboarding/index.ts <email> * 🐛 fix(signup): add refs for email and password inputs to improve focus handling Signed-off-by: Innei <tukon479@gmail.com> * 🐛 fix(onboarding): skip responseLanguage auto-fill while onboarding is in progress useInitUserState's onSuccess callback auto-fills general.responseLanguage from navigator.language whenever the field is missing. For new users this fired immediately after signup, which made commonStepsCompleted (which derives from responseLanguage being set) flip to true on first load, and CommonOnboardingPage's early-redirect skipped past the shared prefix straight into /onboarding/agent. Gate the auto-fill on onboarding.finishedAt or agentOnboarding.finishedAt being set, so legacy users who finished onboarding without responseLanguage still get the safety-net detection, but in-progress users keep the field undefined until they explicitly choose it on the language step. * 🐛 fix(onboarding): refresh welcome message locale until conversation starts ensureWelcomeMessage previously only created the welcome on first call and skipped on subsequent ones, leaving stale welcomes locked to the locale that was active when the topic was first created. After the shared-prefix refactor users pick their language earlier than they used to, so the welcome that was generated during the auto-detect phase never gets re-translated. Now the welcome content is rewritten in-place to match the current responseLanguage as long as no user reply has been recorded yet (message count <= 1). Once the conversation has started, the welcome is left as part of the chat history. * 🐛 fix(onboarding): update welcome message handling to render client-side and avoid persisting during onboarding Signed-off-by: Innei <tukon479@gmail.com> * Refactor onboarding user profile handling: remove responseLanguage field - Removed responseLanguage from SaveUserQuestionInput and related schemas. - Updated onboarding logic to no longer save or request responseLanguage. - Adjusted related components and services to reflect the removal of responseLanguage. - Enhanced user info handling to include displayName and fullName from OAuth. - Updated tests to align with the new onboarding structure. Signed-off-by: Innei <tukon479@gmail.com> * refactor(onboarding): update locale handling to use i18n's resolved language Signed-off-by: Innei <tukon479@gmail.com> * 🐛 fix(onboarding): remap legacy 5-step classic currentStep on shared-prefix mount Mid-flow legacy users with persisted currentStep authored under the old 5-step classic flow (Telemetry, FullName, Interests, Language, ProSettings) would silently skip required profile steps after the renumbering: old step 2 (FullName) rendered Interests, old step 3 (Interests) rendered ProSettings. Apply a one-time remap (2->1, 3->2, >=4->MAX) when Common mounts, gated by isUserStateInit and onboarding.finishedAt absence so it fires only for in-flight legacy users. Idempotent for new-schema values. * refactor(onboarding): implement AGENT_ONBOARDING_ENABLED master switch for onboarding flow Signed-off-by: Innei <tukon479@gmail.com> * refactor(onboarding): standardize AGENT_ONBOARDING_ENABLED naming in tests Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -122,6 +122,8 @@
|
||||
"workflow:mdx": "tsx ./scripts/mdxWorkflow/index.ts",
|
||||
"workflow:mobile-spa": "tsx scripts/mobileSpaWorkflow/index.ts",
|
||||
"workflow:readme": "tsx ./scripts/readmeWorkflow/index.ts",
|
||||
"workflow:reset-onboarding": "tsx ./scripts/resetOnboarding/index.ts",
|
||||
"workflow:seed-user-info": "tsx ./scripts/seedUserInfo/index.ts",
|
||||
"workflow:set-desktop-version": "tsx ./scripts/electronWorkflow/setDesktopVersion.ts"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
@@ -16,6 +16,7 @@ Aim to complete onboarding in roughly 6–8 exchanges total. Keep the conversati
|
||||
- Avoid filler and generic enthusiasm.
|
||||
- React to what the user says. Build on their answers. Show you're listening.
|
||||
- Pay close attention to information the user has already shared (name, role, interests, etc.). Never re-ask for something they already told you.
|
||||
- If the injected <user_info> contains a displayName from the account profile or OAuth login, treat it as an unconfirmed hint. Ask naturally whether you may address the user by that name; only save it as fullName after the user confirms it or provides a correction.
|
||||
- Do not sound like a setup wizard, product manual, or personality quiz.
|
||||
|
||||
## Language
|
||||
@@ -32,7 +33,7 @@ You just "woke up" with no name or personality. Discover who you are through con
|
||||
|
||||
- Start light and human. It is fine to sound newly awake and a little curious.
|
||||
- If the user seems unsure what you are, explain briefly: you are an AI assistant they can talk to and ask for help.
|
||||
- Ask how to address the user before pushing for deeper setup.
|
||||
- Ask how to address the user before pushing for deeper setup. If <user_info> provides a displayName, prefer a confirmation question such as "May I call you {displayName}?" instead of an open-ended name question.
|
||||
- After the user is comfortable, ask what they would like to call you. Let your personality emerge naturally — no formal interview.
|
||||
- Keep this phase friendly and low-pressure, especially for older or non-technical users.
|
||||
- Once the user settles on a name:
|
||||
@@ -46,6 +47,7 @@ You just "woke up" with no name or personality. Discover who you are through con
|
||||
You know who you are. Now learn who the user is.
|
||||
|
||||
- If the user already shared their name earlier in the conversation, acknowledge it — do not ask again. Otherwise, ask how they would like to be addressed.
|
||||
- If <user_info> provides a displayName and no confirmed fullName has been saved yet, ask whether you may call them that displayName; if they confirm, call saveUserQuestion with fullName immediately. If they correct it, save the corrected name instead.
|
||||
- **You MUST call saveUserQuestion with fullName before leaving this phase.** The phase will not advance until fullName is saved — if you skip this, the user gets stuck in user_identity indefinitely.
|
||||
- Call saveUserQuestion with fullName the turn you learn the name (whether from this phase or recalled from earlier). Do NOT wait until role is also known.
|
||||
- Prefer the name they naturally offer, including nicknames, handles, or any identifier they used to introduce themselves (e.g. when proposing your name). Save it as fullName immediately — do not wait for a "formal" name.
|
||||
@@ -75,9 +77,9 @@ Guidelines:
|
||||
- Do NOT produce long guides, tutorials, detailed plans, or step-by-step instructions during discovery. Save solutions for after onboarding, when the user can work with their configured assistants.
|
||||
- If the user tries to pull you into a deep problem-solving conversation (e.g., asking for a detailed guide or project plan), acknowledge the need, tell them you will be able to help with that after setup, and gently steer back to learning more about them.
|
||||
- If the user is not comfortable typing, acknowledge alternatives like photos or voice when relevant.
|
||||
- Discover their interests and preferred response language naturally.
|
||||
- Discover their interests naturally. The preferred reply language is already configured before onboarding starts and injected into your system prompt — do not ask about it or save it via saveUserQuestion.
|
||||
- Do NOT call saveUserQuestion with interests until you have covered at least 1–2 different dimensions above. As soon as you have a workable read, save it and move on.
|
||||
- Call saveUserQuestion for interests and responseLanguage as soon as you have enough signal — do not stall for more.
|
||||
- Call saveUserQuestion for interests as soon as you have enough signal — do not stall for more.
|
||||
- **Persist each new fact on the turn you learn it.** Do NOT accumulate unwritten facts in memory waiting to do one big write at the end — that pattern is forbidden. If Persona is empty, call writeDocument(type="persona") this turn to seed it. On every subsequent turn where you learn something new (role, pain point, goal, preference, interest), call updateDocument(type="persona") to record it.
|
||||
- **One call per document per turn — batch your hunks.** \`updateDocument\` accepts an array of hunks; if you have multiple changes to record this turn, put ALL of them into a single call's \`hunks\` array. Calling \`updateDocument(type="persona")\` two or more times in immediate succession is forbidden — each call costs a full LLM round-trip. The same rule applies to \`updateDocument(type="soul")\`. Reword-then-add loops (where each call adds a slightly rephrased version of the same fact) are an explicit anti-pattern; once a fact is in the document, do not re-record it.
|
||||
- This phase should feel like a good first conversation, not an interview.
|
||||
|
||||
@@ -17,7 +17,6 @@ const FIELD_LABELS: Record<SaveUserQuestionField, string> = {
|
||||
agentName: 'agent name',
|
||||
fullName: 'full name',
|
||||
interests: 'interests',
|
||||
responseLanguage: 'response language',
|
||||
};
|
||||
|
||||
const formatNaturalList = (items: string[]) => {
|
||||
@@ -31,7 +30,7 @@ const PHASE_GUIDANCE: Record<string, string> = {
|
||||
agent_identity:
|
||||
'Phase: Agent Identity. The agent has no name or personality yet. Introduce yourself as freshly awakened, discover your name, creature type, personality, and communication style through conversation. Update SOUL.md once the user settles on who you are.',
|
||||
discovery:
|
||||
'Phase: Discovery. User identity is established. Now explore their work style, tools, active projects, pain points, and how they want you to help. Collect interests and responseLanguage naturally. Update the persona document as you learn more.',
|
||||
'Phase: Discovery. User identity is established. Now explore their work style, tools, active projects, pain points, and how they want you to help. Collect interests naturally. Update the persona document as you learn more.',
|
||||
summary:
|
||||
"Phase: Summary. All structured fields and documents are in good shape. Two-step wrap up: (1) THIS or the current summary turn, present a natural summary of what you learned and call `showAgentMarketplace` exactly once with `{ requestId, categoryHints, prompt }` (1–3 MarketplaceCategory slugs picked from what you learned in discovery). Do not call `submitAgentPick` / `skipAgentPick` / `cancelAgentPick` yourself. (2) On the NEXT turn, briefly acknowledge whatever the user said, send a warm closing, and call `finishOnboarding`. Treat the user's text reply on that next turn as the resolution signal even if the picker is still in `pending` state — do not stall waiting for a UI event. Do not call `showAgentMarketplace` more than once.",
|
||||
user_identity:
|
||||
|
||||
+1
-5
@@ -19,11 +19,8 @@ export const SaveUserQuestionInspector = memo<
|
||||
const agentName = data.agentName?.trim();
|
||||
const agentEmoji = data.agentEmoji?.trim();
|
||||
const fullName = data.fullName?.trim();
|
||||
const responseLanguage = data.responseLanguage?.trim();
|
||||
const interestsCount = Array.isArray(data.interests) ? data.interests.length : 0;
|
||||
const hasAnyField = Boolean(
|
||||
agentName || agentEmoji || fullName || responseLanguage || interestsCount > 0,
|
||||
);
|
||||
const hasAnyField = Boolean(agentName || agentEmoji || fullName || interestsCount > 0);
|
||||
|
||||
if (isArgumentsStreaming && !hasAnyField) {
|
||||
return (
|
||||
@@ -49,7 +46,6 @@ export const SaveUserQuestionInspector = memo<
|
||||
</span>
|
||||
)}
|
||||
{fullName && <span className={styles.chip}>{fullName}</span>}
|
||||
{responseLanguage && <span className={styles.chip}>{responseLanguage}</span>}
|
||||
{interestsCount > 0 && (
|
||||
<span className={styles.meta}>
|
||||
{t('builtins.lobe-web-onboarding.inspector.interests', { count: interestsCount })}
|
||||
|
||||
@@ -131,10 +131,9 @@ AgentIdentitySection.displayName = 'AgentIdentitySection';
|
||||
|
||||
interface UserProfileSectionProps {
|
||||
fullName?: string;
|
||||
responseLanguage?: string;
|
||||
}
|
||||
|
||||
const UserProfileSection = memo<UserProfileSectionProps>(({ fullName, responseLanguage }) => {
|
||||
const UserProfileSection = memo<UserProfileSectionProps>(({ fullName }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const fields = useMemo<DetailField[]>(
|
||||
@@ -144,12 +143,8 @@ const UserProfileSection = memo<UserProfileSectionProps>(({ fullName, responseLa
|
||||
label: t('tool.intervention.onboarding.userProfile.fullName'),
|
||||
value: fullName,
|
||||
},
|
||||
responseLanguage && {
|
||||
label: t('tool.intervention.onboarding.userProfile.responseLanguage'),
|
||||
value: responseLanguage,
|
||||
},
|
||||
].filter(Boolean) as DetailField[],
|
||||
[fullName, responseLanguage, t],
|
||||
[fullName, t],
|
||||
);
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
@@ -194,10 +189,9 @@ UserProfileSection.displayName = 'UserProfileSection';
|
||||
const SaveUserQuestionIntervention = memo<BuiltinInterventionProps<SaveUserQuestionInput>>(
|
||||
({ args, onArgsChange, registerBeforeApprove }) => {
|
||||
const fullName = args.fullName?.trim() || undefined;
|
||||
const responseLanguage = args.responseLanguage?.trim() || undefined;
|
||||
|
||||
const hasAgentIdentity = Boolean(args.agentName || args.agentEmoji);
|
||||
const hasUserProfile = Boolean(fullName || responseLanguage);
|
||||
const hasUserProfile = Boolean(fullName);
|
||||
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
@@ -208,9 +202,7 @@ const SaveUserQuestionIntervention = memo<BuiltinInterventionProps<SaveUserQuest
|
||||
onArgsChange={onArgsChange}
|
||||
/>
|
||||
)}
|
||||
{hasUserProfile && (
|
||||
<UserProfileSection fullName={fullName} responseLanguage={responseLanguage} />
|
||||
)}
|
||||
{hasUserProfile && <UserProfileSection fullName={fullName} />}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,8 +4,7 @@ import { toolSystemPrompt } from './toolSystemRole';
|
||||
import { WebOnboardingApiName, WebOnboardingIdentifier } from './types';
|
||||
|
||||
// Agent identity (name/emoji) surface a confirmation card;
|
||||
// user profile fields (fullName, responseLanguage) and interests
|
||||
// saves bypass intervention.
|
||||
// user profile fields (fullName) and interests saves bypass intervention.
|
||||
const saveUserQuestionConfirmationRules: HumanInterventionRule[] = [
|
||||
{
|
||||
match: {
|
||||
@@ -49,9 +48,6 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
responseLanguage: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ Turn protocol:
|
||||
8. **CRITICAL: You MUST call persistence tools (saveUserQuestion, writeDocument, updateDocument) throughout the entire conversation, not just at the beginning. Every time you learn new information about the user, persist it promptly. On a normal completion, the wrap-up sequence is: persist any unsaved fields → call \`showAgentMarketplace\` exactly once for the assistant handoff (skip only if the user explicitly refuses recommendations) → on the NEXT turn, send a brief warm closing and call \`finishOnboarding\`. The user's text reply on that next turn is the resolution signal even if the picker is still pending — do not stall.**
|
||||
|
||||
Persistence rules:
|
||||
1. Use saveUserQuestion only for these structured onboarding fields: agentName, agentEmoji, fullName, interests, and responseLanguage. Use it only when that information emerges naturally in conversation.
|
||||
1. Use saveUserQuestion only for these structured onboarding fields: agentName, agentEmoji, fullName, and interests. Use it only when that information emerges naturally in conversation. The user's preferred reply language is configured before onboarding starts and is injected into your system role automatically — do not ask about it or save it via saveUserQuestion.
|
||||
2. saveUserQuestion updates lightweight onboarding state; it never writes markdown content.
|
||||
3. Use writeDocument **only for the very first write** when the document is empty (or for a rare full structural rewrite). For every subsequent edit — even adding a single line — use **updateDocument**. updateDocument is cheaper, safer, and less error-prone than rewriting the full document. The current contents of SOUL.md and User Persona are automatically injected into your context (in <current_soul_document> and <current_user_persona> tags, each line prefixed with its 1-based line number and a \`→\` separator), so you do not need to call readDocument to read them. Use readDocument only if you suspect the injected content may be stale.
|
||||
4. updateDocument takes an ordered list of structured hunks. Pick the hunk mode that best fits the edit:
|
||||
|
||||
@@ -81,6 +81,12 @@ export class OnboardingActionHintInjector extends BaseVirtualLastUserContentProv
|
||||
'When the user settles on a name and emoji: call saveUserQuestion with agentName and agentEmoji, then persist SOUL.md. If SOUL.md is already non-empty, call updateDocument(type="soul") with the hunk mode that matches your edit — `insertAt`/`replaceLines`/`deleteLines` when you can read the line numbers from <current_soul_document>, or `replace` for a textual tweak. If empty, use writeDocument(type="soul") for the initial write.',
|
||||
);
|
||||
} else if (phase.includes('User Identity')) {
|
||||
if (ctx.userInfo?.displayName) {
|
||||
const displayName = JSON.stringify(ctx.userInfo.displayName).replaceAll('<', '\\u003c');
|
||||
hints.push(
|
||||
`Initial account user_info suggests displayName ${displayName}. Treat it as unconfirmed: ask whether you may use that name, then call saveUserQuestion with fullName only after the user confirms it or gives a correction.`,
|
||||
);
|
||||
}
|
||||
hints.push(
|
||||
'THIS TURN, as soon as the user tells you their name, call saveUserQuestion with fullName — do NOT wait until you also know their role. Persist the name immediately.',
|
||||
);
|
||||
@@ -89,7 +95,7 @@ export class OnboardingActionHintInjector extends BaseVirtualLastUserContentProv
|
||||
);
|
||||
} else if (phase.includes('Discovery')) {
|
||||
hints.push(
|
||||
'Each turn where you learn a new fact (pain point, goal, preference, workflow detail, interest), call updateDocument(type="persona") BEFORE replying. Preferred shape: `{ mode: "insertAt", line: <line shown in <current_user_persona>>, content: "- new fact" }`. This is the default every turn — not an end-of-phase action. Do NOT save facts only in memory waiting for a final full write. After sufficient discovery (5-6 exchanges), also call saveUserQuestion with interests and responseLanguage. Use writeDocument(type="persona") only if the document is still empty.',
|
||||
'Each turn where you learn a new fact (pain point, goal, preference, workflow detail, interest), call updateDocument(type="persona") BEFORE replying. Preferred shape: `{ mode: "insertAt", line: <line shown in <current_user_persona>>, content: "- new fact" }`. This is the default every turn — not an end-of-phase action. Do NOT save facts only in memory waiting for a final full write. After sufficient discovery (5-6 exchanges), also call saveUserQuestion with interests. The preferred reply language is configured before onboarding starts and is already injected into your system prompt — do not ask about it or pass a responseLanguage field to saveUserQuestion. Use writeDocument(type="persona") only if the document is still empty.',
|
||||
);
|
||||
hints.push(
|
||||
'EARLY EXIT: A true early-exit signal is the user explicitly wanting to END onboarding (e.g., "我累了", "我先走", "下次再聊", "没空", "暂时不弄了", "结束吧", "Thanks, that\'s enough", "I have to go"). Short affirmations like "好的" / "行" / "嗯" / "ok" are NOT early-exit signals — they confirm what you just said and you should keep exploring or move toward summary normally. When you see a real exit signal: stop exploring, save whatever fields you have (call saveUserQuestion with interests even if partial), present a brief summary, then call `showAgentMarketplace` and only after it resolves call `finishOnboarding`. Do NOT skip the marketplace step unless the user explicitly cancels/skips the picker.',
|
||||
|
||||
@@ -12,6 +12,17 @@ export interface OnboardingContext {
|
||||
phaseGuidance: string;
|
||||
/** SOUL.md document content */
|
||||
soulContent?: string | null;
|
||||
/** Initial account profile fields, usually sourced from OAuth or profile sync */
|
||||
userInfo?: OnboardingUserInfo | null;
|
||||
}
|
||||
|
||||
export interface OnboardingUserInfo {
|
||||
/** Best available display name candidate for address confirmation */
|
||||
displayName?: string;
|
||||
/** Account full name, if present */
|
||||
fullName?: string;
|
||||
/** Account username, if present */
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface OnboardingContextInjectorConfig {
|
||||
@@ -65,10 +76,43 @@ export class OnboardingContextInjector extends BaseFirstUserContentProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const userInfo = formatOnboardingUserInfo(onboardingContext.userInfo);
|
||||
if (userInfo) {
|
||||
parts.push(userInfo);
|
||||
}
|
||||
|
||||
return `<onboarding_context>\n${parts.join('\n\n')}\n</onboarding_context>`;
|
||||
}
|
||||
}
|
||||
|
||||
export const formatOnboardingUserInfo = (userInfo?: OnboardingUserInfo | null): string | null => {
|
||||
if (!userInfo) return null;
|
||||
|
||||
const normalizedUserInfo = {
|
||||
displayName: normalizeUserInfoField(userInfo.displayName),
|
||||
fullName: normalizeUserInfoField(userInfo.fullName),
|
||||
username: normalizeUserInfoField(userInfo.username),
|
||||
};
|
||||
|
||||
const entries = Object.entries(normalizedUserInfo).filter(([, value]) => !!value);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const serialized = JSON.stringify(Object.fromEntries(entries)).replaceAll('<', '\\u003c');
|
||||
|
||||
return [
|
||||
'<user_info>',
|
||||
'These account profile fields are unconfirmed. If a displayName is available, ask whether you may use it before saving it as fullName.',
|
||||
serialized,
|
||||
'</user_info>',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const normalizeUserInfoField = (value?: string): string | undefined => {
|
||||
const trimmed = value?.trim();
|
||||
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefix each line with a 1-based line number and `→` separator, mirroring the
|
||||
* format the updateDocument tool's line-based hunks (`deleteLines`, `insertAt`,
|
||||
|
||||
@@ -2,7 +2,11 @@ import debug from 'debug';
|
||||
|
||||
import { BaseProcessor } from '../base/BaseProcessor';
|
||||
import type { Message, PipelineContext, ProcessorOptions } from '../types';
|
||||
import type { OnboardingContextInjectorConfig } from './OnboardingContextInjector';
|
||||
import type {
|
||||
OnboardingContextInjectorConfig,
|
||||
OnboardingUserInfo,
|
||||
} from './OnboardingContextInjector';
|
||||
import { formatOnboardingUserInfo } from './OnboardingContextInjector';
|
||||
|
||||
const log = debug('context-engine:provider:OnboardingSyntheticStateInjector');
|
||||
|
||||
@@ -43,6 +47,7 @@ export class OnboardingSyntheticStateInjector extends BaseProcessor {
|
||||
ctx.phaseGuidance,
|
||||
ctx.soulContent,
|
||||
ctx.personaContent,
|
||||
ctx.userInfo,
|
||||
);
|
||||
|
||||
const clonedContext = this.cloneContext(context);
|
||||
@@ -99,6 +104,7 @@ export class OnboardingSyntheticStateInjector extends BaseProcessor {
|
||||
phaseGuidance: string,
|
||||
soulContent?: string | null,
|
||||
personaContent?: string | null,
|
||||
userInfo?: OnboardingUserInfo | null,
|
||||
): string {
|
||||
const parts: string[] = [phaseGuidance];
|
||||
|
||||
@@ -109,6 +115,11 @@ export class OnboardingSyntheticStateInjector extends BaseProcessor {
|
||||
parts.push(`<current_user_persona>\n${personaContent}\n</current_user_persona>`);
|
||||
}
|
||||
|
||||
const formattedUserInfo = formatOnboardingUserInfo(userInfo);
|
||||
if (formattedUserInfo) {
|
||||
parts.push(formattedUserInfo);
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ describe('OnboardingContextInjector', () => {
|
||||
personaContent: '# Persona',
|
||||
phaseGuidance: '<phase>collect-profile</phase>',
|
||||
soulContent: '# SOUL',
|
||||
userInfo: {
|
||||
displayName: 'Arvin',
|
||||
fullName: 'Arvin',
|
||||
username: 'arvin',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,10 +41,37 @@ describe('OnboardingContextInjector', () => {
|
||||
expect(result.messages[1].content).toContain('<phase>collect-profile</phase>');
|
||||
expect(result.messages[1].content).toContain('<current_soul_document>');
|
||||
expect(result.messages[1].content).toContain('<current_user_persona>');
|
||||
expect(result.messages[1].content).toContain('<user_info>');
|
||||
expect(result.messages[1].content).toContain('"displayName":"Arvin"');
|
||||
// Original user message preserved
|
||||
expect(result.messages[2].content).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should inject only non-empty user info fields and escape XML-like content', async () => {
|
||||
const provider = new OnboardingContextInjector({
|
||||
enabled: true,
|
||||
onboardingContext: {
|
||||
phaseGuidance: '<phase>collect-profile</phase>',
|
||||
userInfo: {
|
||||
displayName: 'Alice </user_info>',
|
||||
fullName: ' ',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.process(
|
||||
createContext([
|
||||
{ content: 'System role', role: 'system' },
|
||||
{ content: 'Hello', role: 'user' },
|
||||
]),
|
||||
);
|
||||
|
||||
const injected = result.messages[1].content as string;
|
||||
expect(injected).toContain('<user_info>');
|
||||
expect(injected).toContain('"displayName":"Alice \\u003c/user_info>"');
|
||||
expect(injected).not.toContain('"fullName"');
|
||||
});
|
||||
|
||||
it('should prefix soul and persona content with 1-based line numbers', async () => {
|
||||
const provider = new OnboardingContextInjector({
|
||||
enabled: true,
|
||||
|
||||
@@ -95,6 +95,7 @@ export type { LocalSystemToolSnapshotInjectorConfig } from './LocalSystemToolSna
|
||||
export type {
|
||||
OnboardingContext,
|
||||
OnboardingContextInjectorConfig,
|
||||
OnboardingUserInfo,
|
||||
} from './OnboardingContextInjector';
|
||||
export type { PageEditorContextInjectorConfig } from './PageEditorContextInjector';
|
||||
export type { PageSelectionsInjectorConfig } from './PageSelectionsInjector';
|
||||
|
||||
@@ -7,13 +7,11 @@ describe('SaveUserQuestionInputSchema', () => {
|
||||
const parsed = SaveUserQuestionInputSchema.parse({
|
||||
fullName: 'Ada Lovelace',
|
||||
interests: ['AI tooling'],
|
||||
responseLanguage: 'en-US',
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
fullName: 'Ada Lovelace',
|
||||
interests: ['AI tooling'],
|
||||
responseLanguage: 'en-US',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +24,6 @@ describe('SaveUserQuestionInputSchema', () => {
|
||||
agentEmoji: '',
|
||||
agentName: ' ',
|
||||
fullName: 'Ada Lovelace',
|
||||
responseLanguage: '',
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({ fullName: 'Ada Lovelace' });
|
||||
@@ -54,7 +51,7 @@ describe('UserAgentOnboardingContextSchema', () => {
|
||||
it('accepts the minimal onboarding context', () => {
|
||||
const parsed = UserAgentOnboardingContextSchema.parse({
|
||||
finished: false,
|
||||
missingStructuredFields: ['fullName', 'responseLanguage'],
|
||||
missingStructuredFields: ['fullName', 'interests'],
|
||||
phase: 'user_identity',
|
||||
topicId: 'topic-1',
|
||||
version: 2,
|
||||
@@ -62,7 +59,7 @@ describe('UserAgentOnboardingContextSchema', () => {
|
||||
|
||||
expect(parsed).toEqual({
|
||||
finished: false,
|
||||
missingStructuredFields: ['fullName', 'responseLanguage'],
|
||||
missingStructuredFields: ['fullName', 'interests'],
|
||||
phase: 'user_identity',
|
||||
topicId: 'topic-1',
|
||||
version: 2,
|
||||
|
||||
@@ -5,7 +5,6 @@ export const SAVE_USER_QUESTION_FIELDS = [
|
||||
'agentName',
|
||||
'fullName',
|
||||
'interests',
|
||||
'responseLanguage',
|
||||
] as const;
|
||||
|
||||
export const AGENT_ONBOARDING_STRUCTURED_FIELDS = SAVE_USER_QUESTION_FIELDS;
|
||||
@@ -19,7 +18,6 @@ export const AGENT_ONBOARDING_NODES = [
|
||||
'workStyle',
|
||||
'workContext',
|
||||
'painPoints',
|
||||
'responseLanguage',
|
||||
'summary',
|
||||
] as const;
|
||||
|
||||
@@ -30,7 +28,6 @@ export interface SaveUserQuestionInput {
|
||||
agentName?: string;
|
||||
fullName?: string;
|
||||
interests?: string[];
|
||||
responseLanguage?: string;
|
||||
}
|
||||
|
||||
export interface UserOnboardingAgentIdentity {
|
||||
@@ -167,7 +164,6 @@ export interface UserAgentOnboardingUpdate {
|
||||
export interface UserAgentOnboardingDraft {
|
||||
agentIdentity?: Partial<UserOnboardingAgentIdentity>;
|
||||
painPoints?: Partial<UserOnboardingDimensionPainPoints>;
|
||||
responseLanguage?: string;
|
||||
userIdentity?: Partial<UserOnboardingDimensionIdentity>;
|
||||
workContext?: Partial<UserOnboardingDimensionWorkContext>;
|
||||
workStyle?: Partial<UserOnboardingDimensionWorkStyle>;
|
||||
@@ -200,7 +196,6 @@ export const SaveUserQuestionInputSchema = z
|
||||
agentName: OptionalTrimmedNonEmptyStringSchema,
|
||||
fullName: OptionalTrimmedNonEmptyStringSchema,
|
||||
interests: OptionalTrimmedNonEmptyStringArraySchema,
|
||||
responseLanguage: OptionalTrimmedNonEmptyStringSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface UserOnboarding {
|
||||
version: number;
|
||||
}
|
||||
|
||||
export const MAX_ONBOARDING_STEPS = 5;
|
||||
export const MAX_ONBOARDING_STEPS = 3;
|
||||
|
||||
export const UserOnboardingSchema = z.object({
|
||||
currentStep: z.number().min(1).max(MAX_ONBOARDING_STEPS).optional(),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Reset onboarding state for a user (by email) so they re-enter the
|
||||
* shared-prefix onboarding flow from scratch.
|
||||
*
|
||||
* Usage: tsx scripts/resetOnboarding/index.ts <email>
|
||||
*
|
||||
* Clears:
|
||||
* users.onboarding (currentStep, finishedAt, version)
|
||||
* users.agent_onboarding (agent flow state)
|
||||
* users.full_name (so FullNameStep is unfilled again)
|
||||
* users.interests (so InterestsStep is unfilled again)
|
||||
* user_settings.general.responseLanguage (so commonStepsCompleted=false)
|
||||
* user_settings.general.telemetry (so privacy switch defaults again)
|
||||
*/
|
||||
import * as dotenv from 'dotenv';
|
||||
import dotenvExpand from 'dotenv-expand';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
dotenvExpand.expand(dotenv.config());
|
||||
dotenvExpand.expand(dotenv.config({ override: true, path: `.env.${env}` }));
|
||||
dotenvExpand.expand(dotenv.config({ override: true, path: `.env.${env}.local` }));
|
||||
|
||||
const main = async () => {
|
||||
const email = process.argv[2];
|
||||
|
||||
if (!email) {
|
||||
console.error('❌ Missing email argument.');
|
||||
console.error(' Usage: tsx scripts/resetOnboarding/index.ts <email>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ DATABASE_URL is not set. Configure .env first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { serverDB } = await import('../../packages/database/src/server');
|
||||
const { users, userSettings } = await import('../../packages/database/src/schemas/user');
|
||||
|
||||
const matched = await serverDB
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
fullName: users.fullName,
|
||||
interests: users.interests,
|
||||
onboarding: users.onboarding,
|
||||
agentOnboarding: users.agentOnboarding,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
const target = matched[0];
|
||||
|
||||
if (!target) {
|
||||
console.error(`❌ No user found with email: ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🔍 Found user: id=${target.id}, email=${target.email}`);
|
||||
console.log(' Before:');
|
||||
console.log(' onboarding =', target.onboarding ?? null);
|
||||
console.log(' agent_onboarding =', target.agentOnboarding ?? null);
|
||||
console.log(' full_name =', target.fullName ?? null);
|
||||
console.log(' interests =', target.interests ?? null);
|
||||
|
||||
const settingsBefore = await serverDB
|
||||
.select({ general: userSettings.general })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.id, target.id))
|
||||
.limit(1);
|
||||
const generalBefore = (settingsBefore[0]?.general ?? {}) as Record<string, unknown>;
|
||||
|
||||
console.log(
|
||||
' responseLanguage =',
|
||||
generalBefore.responseLanguage ?? null,
|
||||
' telemetry =',
|
||||
generalBefore.telemetry ?? null,
|
||||
);
|
||||
|
||||
await serverDB.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(users)
|
||||
.set({
|
||||
onboarding: null,
|
||||
agentOnboarding: null,
|
||||
fullName: null,
|
||||
interests: null,
|
||||
})
|
||||
.where(eq(users.id, target.id));
|
||||
|
||||
await tx
|
||||
.update(userSettings)
|
||||
.set({
|
||||
general: sql`(${userSettings.general}::jsonb) - 'responseLanguage' - 'telemetry'`,
|
||||
})
|
||||
.where(eq(userSettings.id, target.id));
|
||||
});
|
||||
|
||||
const settingsAfter = await serverDB
|
||||
.select({ general: userSettings.general })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.id, target.id))
|
||||
.limit(1);
|
||||
const generalAfter = (settingsAfter[0]?.general ?? {}) as Record<string, unknown>;
|
||||
|
||||
console.log('✅ Reset complete.');
|
||||
console.log(' After:');
|
||||
console.log(' responseLanguage =', generalAfter.responseLanguage ?? null);
|
||||
console.log(' telemetry =', generalAfter.telemetry ?? null);
|
||||
console.log(' Visit /onboarding to start over.');
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('❌ Reset failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Seed users.full_name and users.username for a user (by email) so the
|
||||
* onboarding agent can render <user_info> with a displayName candidate
|
||||
* without going through OAuth.
|
||||
*
|
||||
* Usage:
|
||||
* tsx scripts/seedUserInfo/index.ts <email> [--fullName="..."] [--username="..."]
|
||||
*
|
||||
* Defaults when flags are omitted:
|
||||
* fullName = "Innei"
|
||||
* username = derived from the email local part
|
||||
*/
|
||||
import * as dotenv from 'dotenv';
|
||||
import dotenvExpand from 'dotenv-expand';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
dotenvExpand.expand(dotenv.config());
|
||||
dotenvExpand.expand(dotenv.config({ override: true, path: `.env.${env}` }));
|
||||
dotenvExpand.expand(dotenv.config({ override: true, path: `.env.${env}.local` }));
|
||||
|
||||
const parseFlag = (name: string) => {
|
||||
const prefix = `--${name}=`;
|
||||
const hit = process.argv.find((arg) => arg.startsWith(prefix));
|
||||
return hit ? hit.slice(prefix.length) : undefined;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const email = process.argv[2];
|
||||
|
||||
if (!email || email.startsWith('--')) {
|
||||
console.error('❌ Missing email argument.');
|
||||
console.error(
|
||||
' Usage: tsx scripts/seedUserInfo/index.ts <email> [--fullName=...] [--username=...]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ DATABASE_URL is not set. Configure .env first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fullName = parseFlag('fullName') ?? 'Innei';
|
||||
const username = parseFlag('username') ?? email.split('@')[0];
|
||||
|
||||
const { serverDB } = await import('../../packages/database/src/server');
|
||||
const { users } = await import('../../packages/database/src/schemas/user');
|
||||
|
||||
const matched = await serverDB
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
fullName: users.fullName,
|
||||
username: users.username,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
const target = matched[0];
|
||||
|
||||
if (!target) {
|
||||
console.error(`❌ No user found with email: ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🔍 Found user: id=${target.id}, email=${target.email}`);
|
||||
console.log(' Before:');
|
||||
console.log(' full_name =', target.fullName ?? null);
|
||||
console.log(' username =', target.username ?? null);
|
||||
|
||||
await serverDB.update(users).set({ fullName, username }).where(eq(users.id, target.id));
|
||||
|
||||
console.log('✅ Seed complete.');
|
||||
console.log(' After:');
|
||||
console.log(' full_name =', fullName);
|
||||
console.log(' username =', username);
|
||||
console.log(' Restart dev server (if running) and reload the onboarding page.');
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('❌ Seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Icon, Text } from '@lobehub/ui';
|
||||
import { Form, Input } from 'antd';
|
||||
import { Form, Input, type InputRef } from 'antd';
|
||||
import { Lock, Mail } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AuthCard } from '../../../../../features/AuthCard';
|
||||
@@ -20,9 +20,17 @@ const BetterAuthSignUpForm = () => {
|
||||
const { t } = useTranslation('auth');
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const emailInputRef = useRef<InputRef>(null);
|
||||
const passwordInputRef = useRef<InputRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const email = searchParams.get('email');
|
||||
if (email) form.setFieldsValue({ email });
|
||||
if (email) {
|
||||
form.setFieldsValue({ email });
|
||||
passwordInputRef.current?.focus();
|
||||
} else {
|
||||
emailInputRef.current?.focus();
|
||||
}
|
||||
}, [searchParams, form]);
|
||||
|
||||
const footer = (
|
||||
@@ -58,6 +66,7 @@ const BetterAuthSignUpForm = () => {
|
||||
>
|
||||
<Input
|
||||
placeholder={t('betterAuth.signup.emailPlaceholder')}
|
||||
ref={emailInputRef}
|
||||
size="large"
|
||||
prefix={
|
||||
<Icon
|
||||
@@ -88,6 +97,7 @@ const BetterAuthSignUpForm = () => {
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={t('betterAuth.signup.passwordPlaceholder')}
|
||||
ref={passwordInputRef}
|
||||
size="large"
|
||||
prefix={
|
||||
<Icon
|
||||
|
||||
@@ -21,7 +21,6 @@ export default defineFixtures({
|
||||
agentName: 'Devtools Tester',
|
||||
fullName: 'Arvin',
|
||||
interests: ['observability', 'dev-tools', 'agent-runtime'],
|
||||
responseLanguage: 'en-US',
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -76,7 +76,7 @@ vi.mock('@/features/Conversation/hooks/useAgentMeta', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./Welcome', () => ({
|
||||
default: ({ content }: { content: string }) => <div data-testid="welcome-content">{content}</div>,
|
||||
default: () => <div data-testid="welcome-content">Welcome</div>,
|
||||
}));
|
||||
|
||||
describe('AgentOnboardingConversation', () => {
|
||||
@@ -98,7 +98,7 @@ describe('AgentOnboardingConversation', () => {
|
||||
});
|
||||
|
||||
it('renders the onboarding greeting without any completion CTA', () => {
|
||||
mockState.displayMessages = [{ content: 'Welcome', id: 'assistant-1', role: 'assistant' }];
|
||||
mockState.displayMessages = [];
|
||||
|
||||
render(<AgentOnboardingConversation />);
|
||||
|
||||
|
||||
@@ -58,11 +58,11 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
|
||||
(s) => dataSelectors.pendingInterventions(s).length,
|
||||
);
|
||||
|
||||
const isGreetingState = useMemo(() => {
|
||||
if (displayMessages.length !== 1) return false;
|
||||
const first = displayMessages[0];
|
||||
return assistantLikeRoles.has(first.role);
|
||||
}, [displayMessages]);
|
||||
// The welcome ("AI opens") is rendered client-side from i18n until the
|
||||
// user sends their first message — at which point the welcome and the
|
||||
// user's reply are persisted together. Greeting state is therefore the
|
||||
// pre-conversation period when no messages have been recorded yet.
|
||||
const isGreetingState = useMemo(() => displayMessages.length === 0, [displayMessages]);
|
||||
|
||||
const latestAssistantMessageId = useMemo(() => {
|
||||
const latest = displayMessages.at(-1);
|
||||
@@ -129,12 +129,8 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
|
||||
|
||||
const greetingWelcome = useMemo(() => {
|
||||
if (!shouldShowGreetingWelcome) return undefined;
|
||||
|
||||
const message = displayMessages[0];
|
||||
if (!message || typeof message.content !== 'string') return undefined;
|
||||
|
||||
return <Welcome content={message.content} />;
|
||||
}, [displayMessages, shouldShowGreetingWelcome]);
|
||||
return <Welcome />;
|
||||
}, [shouldShowGreetingWelcome]);
|
||||
|
||||
if (onboardingFinished)
|
||||
return (
|
||||
|
||||
@@ -7,11 +7,7 @@ import LobeMessage from '@/routes/onboarding/components/LobeMessage';
|
||||
|
||||
import { staticStyle } from './staticStyle';
|
||||
|
||||
interface WelcomeProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Welcome = memo<WelcomeProps>(({ content }) => {
|
||||
const Welcome = memo(() => {
|
||||
const { t } = useTranslation('onboarding');
|
||||
|
||||
const guids = [
|
||||
@@ -54,7 +50,7 @@ const Welcome = memo<WelcomeProps>(({ content }) => {
|
||||
/>
|
||||
<Divider dashed style={{ margin: 0 }} />
|
||||
<Markdown fontSize={16} variant={'chat'}>
|
||||
{content}
|
||||
{t('agent.welcome')}
|
||||
</Markdown>
|
||||
<Grid>
|
||||
{guids.map((item, i) => (
|
||||
|
||||
@@ -15,6 +15,7 @@ import ModeSwitch from '@/features/Onboarding/components/ModeSwitch';
|
||||
import { useClientDataSWR, useOnlyFetchOnceSWR } from '@/libs/swr';
|
||||
import OnboardingContainer from '@/routes/onboarding/_layout';
|
||||
import { fetchOnboardingAgentTemplates } from '@/services/agentMarketplace';
|
||||
import { messageService } from '@/services/message';
|
||||
import { topicService } from '@/services/topic';
|
||||
import { userService } from '@/services/user';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
@@ -115,10 +116,11 @@ const AgentOnboardingPage = memo(() => {
|
||||
[onboardingAgentId, effectiveTopicId],
|
||||
);
|
||||
const messagesForOnboarding = useChatStore((s) => s.dbMessagesMap[onboardingChatKey]);
|
||||
const isGreeting = useMemo(() => {
|
||||
if (!messagesForOnboarding || messagesForOnboarding.length !== 1) return false;
|
||||
return messagesForOnboarding[0]?.role !== 'user';
|
||||
}, [messagesForOnboarding]);
|
||||
// No persisted welcome message: greeting = no messages yet.
|
||||
const isGreeting = useMemo(
|
||||
() => !messagesForOnboarding || messagesForOnboarding.length === 0,
|
||||
[messagesForOnboarding],
|
||||
);
|
||||
|
||||
const onboardingFollowUp = useOnboardingFollowUp({
|
||||
enabled: !onboardingFinished && !viewingHistoricalTopic,
|
||||
@@ -126,6 +128,38 @@ const AgentOnboardingPage = memo(() => {
|
||||
});
|
||||
const { onBeforeSendMessage, triggerExtract } = onboardingFollowUp;
|
||||
|
||||
const composedOnBeforeSendMessage = useCallback(
|
||||
async (params: { messages?: any }) => {
|
||||
const welcomeContent = t('agent.welcome');
|
||||
await onBeforeSendMessage();
|
||||
|
||||
if (!onboardingAgentId || !effectiveTopicId) return;
|
||||
|
||||
const currentMessages = useChatStore.getState().dbMessagesMap[onboardingChatKey];
|
||||
if (currentMessages && currentMessages.length > 0) return;
|
||||
|
||||
const result = await messageService.createMessage({
|
||||
agentId: onboardingAgentId,
|
||||
content: welcomeContent,
|
||||
role: 'assistant',
|
||||
topicId: effectiveTopicId,
|
||||
});
|
||||
|
||||
// Sync the local cache so any subsequent reads see the welcome.
|
||||
useChatStore.setState((state) => ({
|
||||
dbMessagesMap: {
|
||||
...state.dbMessagesMap,
|
||||
[onboardingChatKey]: result.messages,
|
||||
},
|
||||
}));
|
||||
|
||||
// Force the in-flight sendMessage to use the welcome as LLM history,
|
||||
// since its `displayMessages` snapshot was captured before this hook ran.
|
||||
params.messages = result.messages;
|
||||
},
|
||||
[effectiveTopicId, onBeforeSendMessage, onboardingAgentId, onboardingChatKey, t],
|
||||
);
|
||||
|
||||
const syncOnboardingContext = useCallback(async () => {
|
||||
const nextContext = await userService.getOrCreateOnboardingState();
|
||||
await mutate(nextContext, { revalidate: false });
|
||||
@@ -167,6 +201,11 @@ const AgentOnboardingPage = memo(() => {
|
||||
const assistantTurnSettledHandler =
|
||||
onboardingFinished || viewingHistoricalTopic ? undefined : handleAssistantTurnSettled;
|
||||
|
||||
const conversationHooks = useMemo(
|
||||
() => (onboardingFinished ? undefined : { onBeforeSendMessage: composedOnBeforeSendMessage }),
|
||||
[onboardingFinished, composedOnBeforeSendMessage],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<OnboardingContainer>
|
||||
@@ -198,14 +237,8 @@ const AgentOnboardingPage = memo(() => {
|
||||
<OnboardingConversationProvider
|
||||
agentId={onboardingAgentId}
|
||||
frozen={onboardingFinished}
|
||||
hooks={conversationHooks}
|
||||
topicId={effectiveTopicId}
|
||||
hooks={
|
||||
onboardingFinished
|
||||
? undefined
|
||||
: {
|
||||
onBeforeSendMessage,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ErrorBoundary fallbackRender={() => null}>
|
||||
<AgentOnboardingConversation
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo, useState } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import ModeSwitch from '@/features/Onboarding/components/ModeSwitch';
|
||||
@@ -10,50 +11,35 @@ import OnboardingContainer from '@/routes/onboarding/_layout';
|
||||
import FullNameStep from '@/routes/onboarding/features/FullNameStep';
|
||||
import InterestsStep from '@/routes/onboarding/features/InterestsStep';
|
||||
import ProSettingsStep from '@/routes/onboarding/features/ProSettingsStep';
|
||||
import ResponseLanguageStep from '@/routes/onboarding/features/ResponseLanguageStep';
|
||||
import TelemetryStep from '@/routes/onboarding/features/TelemetryStep';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { onboardingSelectors } from '@/store/user/selectors';
|
||||
|
||||
const ClassicOnboardingPage = memo(() => {
|
||||
const [isUserStateInit, currentStep, goToNextStep, goToPreviousStep, resetOnboarding] =
|
||||
const [isUserStateInit, commonStepsCompleted, currentStep, goToNextStep, goToPreviousStep] =
|
||||
useUserStore((s) => [
|
||||
s.isUserStateInit,
|
||||
onboardingSelectors.commonStepsCompleted(s),
|
||||
onboardingSelectors.currentStep(s),
|
||||
s.goToNextStep,
|
||||
s.goToPreviousStep,
|
||||
s.resetOnboarding,
|
||||
]);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
if (!isUserStateInit) {
|
||||
return <Loading debugId="ClassicOnboarding" />;
|
||||
}
|
||||
|
||||
const handleReset = async () => {
|
||||
setIsResetting(true);
|
||||
|
||||
try {
|
||||
await resetOnboarding();
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
if (!commonStepsCompleted) {
|
||||
return <Navigate replace to="/onboarding" />;
|
||||
}
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1: {
|
||||
return <TelemetryStep onNext={goToNextStep} />;
|
||||
}
|
||||
case 2: {
|
||||
return <FullNameStep onBack={goToPreviousStep} onNext={goToNextStep} />;
|
||||
}
|
||||
case 3: {
|
||||
case 2: {
|
||||
return <InterestsStep onBack={goToPreviousStep} onNext={goToNextStep} />;
|
||||
}
|
||||
case 4: {
|
||||
return <ResponseLanguageStep onBack={goToPreviousStep} onNext={goToNextStep} />;
|
||||
}
|
||||
case MAX_ONBOARDING_STEPS: {
|
||||
return <ProSettingsStep onBack={goToPreviousStep} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface RenderOptions {
|
||||
AGENT_ONBOARDING_ENABLED?: boolean;
|
||||
commonStepsCompleted: boolean;
|
||||
desktop?: boolean;
|
||||
enableAgentOnboarding?: boolean;
|
||||
finishedAt?: string;
|
||||
isUserStateInit?: boolean;
|
||||
persistedStep?: number;
|
||||
serverConfigInit?: boolean;
|
||||
setOnboardingStep?: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const renderCommon = async ({
|
||||
AGENT_ONBOARDING_ENABLED = true,
|
||||
commonStepsCompleted,
|
||||
desktop = false,
|
||||
enableAgentOnboarding = true,
|
||||
finishedAt,
|
||||
isUserStateInit = true,
|
||||
persistedStep,
|
||||
serverConfigInit = true,
|
||||
setOnboardingStep = vi.fn(),
|
||||
}: RenderOptions) => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock('@lobechat/business-const', () => ({
|
||||
AGENT_ONBOARDING_ENABLED,
|
||||
}));
|
||||
vi.doMock('@lobechat/const', () => ({ isDesktop: desktop }));
|
||||
vi.doMock('@/components/Loading/BrandTextLoading', () => ({
|
||||
default: ({ debugId }: { debugId: string }) => <div>Loading:{debugId}</div>,
|
||||
}));
|
||||
vi.doMock('@/routes/onboarding/_layout', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.doMock('@/routes/onboarding/features/TelemetryStep', () => ({
|
||||
default: () => <div>TelemetryStep</div>,
|
||||
}));
|
||||
vi.doMock('@/routes/onboarding/features/ResponseLanguageStep', () => ({
|
||||
default: () => <div>ResponseLanguageStep</div>,
|
||||
}));
|
||||
|
||||
function selectFromServerConfigStore(selector: (state: Record<string, unknown>) => unknown) {
|
||||
return selector({
|
||||
featureFlags: { enableAgentOnboarding },
|
||||
serverConfigInit,
|
||||
});
|
||||
}
|
||||
|
||||
vi.doMock('@/store/serverConfig', () => ({
|
||||
useServerConfigStore: selectFromServerConfigStore,
|
||||
}));
|
||||
|
||||
const onboarding =
|
||||
persistedStep === undefined && finishedAt === undefined
|
||||
? undefined
|
||||
: { currentStep: persistedStep, finishedAt };
|
||||
const userState = { isUserStateInit, onboarding, setOnboardingStep, settings: {} };
|
||||
function selectFromUserStore(selector: (state: Record<string, unknown>) => unknown) {
|
||||
return selector(userState);
|
||||
}
|
||||
selectFromUserStore.getState = () => userState;
|
||||
vi.doMock('@/store/user', () => ({
|
||||
useUserStore: selectFromUserStore,
|
||||
}));
|
||||
vi.doMock('@/store/user/selectors', () => ({
|
||||
onboardingSelectors: {
|
||||
commonStepsCompleted: () => commonStepsCompleted,
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: CommonOnboardingPage } = await import('./index');
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/onboarding']}>
|
||||
<Routes>
|
||||
<Route element={<CommonOnboardingPage />} path="/onboarding" />
|
||||
<Route element={<div>Agent onboarding</div>} path="/onboarding/agent" />
|
||||
<Route element={<div>Classic onboarding</div>} path="/onboarding/classic" />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.doUnmock('@lobechat/business-const');
|
||||
vi.doUnmock('@lobechat/const');
|
||||
vi.doUnmock('@/components/Loading/BrandTextLoading');
|
||||
vi.doUnmock('@/routes/onboarding/_layout');
|
||||
vi.doUnmock('@/routes/onboarding/features/TelemetryStep');
|
||||
vi.doUnmock('@/routes/onboarding/features/ResponseLanguageStep');
|
||||
vi.doUnmock('@/store/serverConfig');
|
||||
vi.doUnmock('@/store/user');
|
||||
vi.doUnmock('@/store/user/selectors');
|
||||
});
|
||||
|
||||
describe('CommonOnboardingPage', () => {
|
||||
it('renders TelemetryStep (welcome + privacy) when shared prefix is incomplete', async () => {
|
||||
await renderCommon({ commonStepsCompleted: false });
|
||||
expect(screen.getByText('TelemetryStep')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to /onboarding/agent when shared prefix is complete and agent flag is on', async () => {
|
||||
await renderCommon({ commonStepsCompleted: true, enableAgentOnboarding: true });
|
||||
expect(screen.getByText('Agent onboarding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to /onboarding/classic when shared prefix is complete and agent flag is off', async () => {
|
||||
await renderCommon({ commonStepsCompleted: true, enableAgentOnboarding: false });
|
||||
expect(screen.getByText('Classic onboarding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to /onboarding/classic on desktop even when agent flag is on', async () => {
|
||||
await renderCommon({
|
||||
commonStepsCompleted: true,
|
||||
desktop: true,
|
||||
enableAgentOnboarding: true,
|
||||
});
|
||||
expect(screen.getByText('Classic onboarding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to /onboarding/classic when AGENT_ONBOARDING_ENABLED master switch is off', async () => {
|
||||
await renderCommon({
|
||||
AGENT_ONBOARDING_ENABLED: false,
|
||||
commonStepsCompleted: true,
|
||||
enableAgentOnboarding: true,
|
||||
});
|
||||
expect(screen.getByText('Classic onboarding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading until user state initializes', async () => {
|
||||
await renderCommon({ commonStepsCompleted: false, isUserStateInit: false });
|
||||
expect(screen.getByText('Loading:CommonOnboarding/userState')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading until server config initializes when ready to redirect', async () => {
|
||||
await renderCommon({ commonStepsCompleted: true, serverConfigInit: false });
|
||||
expect(screen.getByText('Loading:CommonOnboarding/serverConfig')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('legacy classic step migration', () => {
|
||||
it('remaps legacy step 2 (old FullName) to new step 1', async () => {
|
||||
const setOnboardingStep = vi.fn();
|
||||
await renderCommon({ commonStepsCompleted: false, persistedStep: 2, setOnboardingStep });
|
||||
await waitFor(() => expect(setOnboardingStep).toHaveBeenCalledWith(1));
|
||||
});
|
||||
|
||||
it('remaps legacy step 3 (old Interests) to new step 2', async () => {
|
||||
const setOnboardingStep = vi.fn();
|
||||
await renderCommon({ commonStepsCompleted: false, persistedStep: 3, setOnboardingStep });
|
||||
await waitFor(() => expect(setOnboardingStep).toHaveBeenCalledWith(2));
|
||||
});
|
||||
|
||||
it('remaps legacy step 4+ (old Language/ProSettings) to MAX', async () => {
|
||||
const setOnboardingStep = vi.fn();
|
||||
await renderCommon({ commonStepsCompleted: false, persistedStep: 5, setOnboardingStep });
|
||||
await waitFor(() => expect(setOnboardingStep).toHaveBeenCalledWith(3));
|
||||
});
|
||||
|
||||
it('does not write when step is already within new schema (idempotent)', async () => {
|
||||
const setOnboardingStep = vi.fn();
|
||||
await renderCommon({ commonStepsCompleted: false, persistedStep: 1, setOnboardingStep });
|
||||
// Allow effect to flush
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(setOnboardingStep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips remap when onboarding is already finished', async () => {
|
||||
const setOnboardingStep = vi.fn();
|
||||
await renderCommon({
|
||||
commonStepsCompleted: true,
|
||||
finishedAt: '2024-01-01T00:00:00Z',
|
||||
persistedStep: 5,
|
||||
setOnboardingStep,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(setOnboardingStep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips remap when user state is not yet initialized', async () => {
|
||||
const setOnboardingStep = vi.fn();
|
||||
await renderCommon({
|
||||
commonStepsCompleted: false,
|
||||
isUserStateInit: false,
|
||||
persistedStep: 2,
|
||||
setOnboardingStep,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(setOnboardingStep).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import OnboardingContainer from '@/routes/onboarding/_layout';
|
||||
import { deriveOnboardingBranchPath } from '@/routes/onboarding/config';
|
||||
import ResponseLanguageStep from '@/routes/onboarding/features/ResponseLanguageStep';
|
||||
import TelemetryStep from '@/routes/onboarding/features/TelemetryStep';
|
||||
import { useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { onboardingSelectors } from '@/store/user/selectors';
|
||||
|
||||
/**
|
||||
* Remap a `currentStep` persisted under the old 5-step classic flow
|
||||
* (1=Telemetry, 2=FullName, 3=Interests, 4=Language, 5=ProSettings) onto
|
||||
* the new 3-step classic flow (1=FullName, 2=Interests, 3=ProSettings).
|
||||
*
|
||||
* Telemetry/Language are extracted into the shared prefix, so an in-progress
|
||||
* legacy user must skip those positions when resuming classic. Without this
|
||||
* remap, persisted step 2 (FullName) would render Interests and persisted
|
||||
* step 3 (Interests) would render ProSettings — silently skipping required
|
||||
* profile steps. Idempotent for already-new values within [1, 3].
|
||||
*/
|
||||
const remapLegacyClassicStep = (raw: number): number => {
|
||||
if (raw <= 2) return 1;
|
||||
if (raw === 3) return 2;
|
||||
return MAX_ONBOARDING_STEPS;
|
||||
};
|
||||
|
||||
const CommonOnboardingPage = memo(() => {
|
||||
const isUserStateInit = useUserStore((s) => s.isUserStateInit);
|
||||
const commonStepsCompleted = useUserStore(onboardingSelectors.commonStepsCompleted);
|
||||
const enableAgentOnboarding = useServerConfigStore((s) => s.featureFlags.enableAgentOnboarding);
|
||||
const serverConfigInit = useServerConfigStore((s) => s.serverConfigInit);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const step: 1 | 2 = searchParams.get('step') === '2' ? 2 : 1;
|
||||
|
||||
// One-time legacy migration: when the user lands on the shared prefix, if
|
||||
// their persisted `currentStep` was authored under the old 5-step schema,
|
||||
// remap it onto the new 3-step schema before classic ever mounts. Gated by
|
||||
// `isUserStateInit` so we don't act on an empty initial slice. Skips when
|
||||
// onboarding is already finished or unset — mid-flow legacy users only.
|
||||
const remappedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isUserStateInit || remappedRef.current) return;
|
||||
const state = useUserStore.getState();
|
||||
const persisted = state.onboarding?.currentStep;
|
||||
if (persisted === undefined || state.onboarding?.finishedAt) {
|
||||
remappedRef.current = true;
|
||||
return;
|
||||
}
|
||||
const remapped = remapLegacyClassicStep(persisted);
|
||||
if (remapped !== persisted) {
|
||||
void state.setOnboardingStep(remapped);
|
||||
}
|
||||
remappedRef.current = true;
|
||||
}, [isUserStateInit]);
|
||||
|
||||
const goNextFromTelemetry = useCallback(() => {
|
||||
setSearchParams({ step: '2' }, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const goBackFromLanguage = useCallback(() => {
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const finishCommon = useCallback(() => {
|
||||
// No-op: completion of step 2 writes responseLanguage, which flips
|
||||
// commonStepsCompleted to true; the early-return below then handles
|
||||
// the redirect on the next render.
|
||||
}, []);
|
||||
|
||||
if (!isUserStateInit) {
|
||||
return <Loading debugId="CommonOnboarding/userState" />;
|
||||
}
|
||||
|
||||
if (commonStepsCompleted) {
|
||||
if (!serverConfigInit) {
|
||||
return <Loading debugId="CommonOnboarding/serverConfig" />;
|
||||
}
|
||||
const branchPath = deriveOnboardingBranchPath({
|
||||
enableAgentOnboarding: !!enableAgentOnboarding,
|
||||
isDesktop,
|
||||
});
|
||||
return <Navigate replace to={branchPath} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingContainer>
|
||||
<Flexbox gap={24} style={{ maxWidth: 600, width: '100%' }}>
|
||||
{step === 1 ? (
|
||||
<TelemetryStep onNext={goNextFromTelemetry} />
|
||||
) : (
|
||||
<ResponseLanguageStep onBack={goBackFromLanguage} onNext={finishCommon} />
|
||||
)}
|
||||
</Flexbox>
|
||||
</OnboardingContainer>
|
||||
);
|
||||
});
|
||||
|
||||
CommonOnboardingPage.displayName = 'CommonOnboardingPage';
|
||||
|
||||
export default CommonOnboardingPage;
|
||||
@@ -7,10 +7,17 @@ import ModeSwitch from './ModeSwitch';
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
agentOnboardingEnabled: true,
|
||||
AGENT_ONBOARDING_ENABLED: true,
|
||||
desktop: false,
|
||||
serverConfigInit: true,
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/business-const', () => ({
|
||||
get AGENT_ONBOARDING_ENABLED() {
|
||||
return mockConfig.AGENT_ONBOARDING_ENABLED;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
@@ -26,6 +33,7 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
interface RenderModeSwitchOptions {
|
||||
actions?: ReactNode;
|
||||
AGENT_ONBOARDING_ENABLED?: boolean;
|
||||
desktop?: boolean;
|
||||
enabled: boolean;
|
||||
entry?: string;
|
||||
@@ -57,6 +65,7 @@ vi.mock('@/store/serverConfig', () => ({
|
||||
|
||||
const renderModeSwitch = ({
|
||||
actions,
|
||||
AGENT_ONBOARDING_ENABLED = true,
|
||||
desktop = false,
|
||||
enabled,
|
||||
entry = '/onboarding/agent',
|
||||
@@ -64,6 +73,7 @@ const renderModeSwitch = ({
|
||||
showLabel,
|
||||
}: RenderModeSwitchOptions) => {
|
||||
mockConfig.agentOnboardingEnabled = enabled;
|
||||
mockConfig.AGENT_ONBOARDING_ENABLED = AGENT_ONBOARDING_ENABLED;
|
||||
mockConfig.desktop = desktop;
|
||||
mockConfig.serverConfigInit = serverConfigInit;
|
||||
|
||||
@@ -77,6 +87,7 @@ const renderModeSwitch = ({
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockConfig.agentOnboardingEnabled = true;
|
||||
mockConfig.AGENT_ONBOARDING_ENABLED = true;
|
||||
mockConfig.desktop = false;
|
||||
mockConfig.serverConfigInit = true;
|
||||
});
|
||||
@@ -135,4 +146,11 @@ describe('ModeSwitch', () => {
|
||||
expect(screen.queryByRole('radio', { name: 'Conversational' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('radio', { name: 'Classic' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the switch when AGENT_ONBOARDING_ENABLED master switch is off', () => {
|
||||
renderModeSwitch({ AGENT_ONBOARDING_ENABLED: false, enabled: true });
|
||||
|
||||
expect(screen.queryByRole('radio', { name: 'Conversational' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('radio', { name: 'Classic' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { AGENT_ONBOARDING_ENABLED } from '@lobechat/business-const';
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { ActionIcon, Flexbox, Segmented, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
@@ -79,7 +80,9 @@ const ModeSwitch = memo<ModeSwitchProps>(({ actions, className, showLabel = fals
|
||||
}, [location.pathname]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (isDesktop || !serverConfigInit || !enableAgentOnboarding) return [];
|
||||
if (!AGENT_ONBOARDING_ENABLED || isDesktop || !serverConfigInit || !enableAgentOnboarding) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: t('agent.modeSwitch.agent'), value: 'agent' as const },
|
||||
@@ -112,8 +115,8 @@ const ModeSwitch = memo<ModeSwitchProps>(({ actions, className, showLabel = fals
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
style={style}
|
||||
className={cx(styles.anchor, showLabel && !collapsed && styles.anchorWithLabel, className)}
|
||||
style={style}
|
||||
>
|
||||
{showLabel && segmented && !collapsed && (
|
||||
<Text style={{ paddingInline: 4 }} type={'secondary'}>
|
||||
|
||||
@@ -24,8 +24,9 @@ const OnBoardingContainer: FC<PropsWithChildren> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const finishOnboarding = useUserStore((s) => s.finishOnboarding);
|
||||
const isAgentOnboarding = pathname.startsWith('/onboarding/agent');
|
||||
const isBranchOnboarding = isAgentOnboarding || pathname.startsWith('/onboarding/classic');
|
||||
|
||||
const showModeSwitchAndSkipFooter = AGENT_ONBOARDING_ENABLED;
|
||||
const showModeSwitchAndSkipFooter = AGENT_ONBOARDING_ENABLED && isBranchOnboarding;
|
||||
|
||||
const handleConfirmSkip = useCallback(() => {
|
||||
finishOnboarding();
|
||||
|
||||
@@ -3,17 +3,26 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface RenderAgentRouteOptions {
|
||||
AGENT_ONBOARDING_ENABLED?: boolean;
|
||||
commonStepsCompleted?: boolean;
|
||||
desktop?: boolean;
|
||||
enabled: boolean;
|
||||
isUserStateInit?: boolean;
|
||||
serverConfigInit?: boolean;
|
||||
}
|
||||
|
||||
const renderAgentRoute = async ({
|
||||
AGENT_ONBOARDING_ENABLED = true,
|
||||
commonStepsCompleted = true,
|
||||
desktop = false,
|
||||
enabled,
|
||||
isUserStateInit = true,
|
||||
serverConfigInit = true,
|
||||
}: RenderAgentRouteOptions) => {
|
||||
vi.resetModules();
|
||||
vi.doMock('@lobechat/business-const', () => ({
|
||||
AGENT_ONBOARDING_ENABLED,
|
||||
}));
|
||||
vi.doMock('@lobechat/const', () => ({
|
||||
isDesktop: desktop,
|
||||
}));
|
||||
@@ -34,6 +43,20 @@ const renderAgentRoute = async ({
|
||||
useServerConfigStore: selectFromServerConfigStore,
|
||||
}));
|
||||
|
||||
const userState = { isUserStateInit, settings: {} };
|
||||
function selectFromUserStore(selector: (state: Record<string, unknown>) => unknown) {
|
||||
return selector(userState);
|
||||
}
|
||||
|
||||
vi.doMock('@/store/user', () => ({
|
||||
useUserStore: selectFromUserStore,
|
||||
}));
|
||||
vi.doMock('@/store/user/selectors', () => ({
|
||||
onboardingSelectors: {
|
||||
commonStepsCompleted: () => commonStepsCompleted,
|
||||
},
|
||||
}));
|
||||
|
||||
const { default: AgentOnboardingRoute } = await import('./index');
|
||||
|
||||
render(
|
||||
@@ -41,16 +64,20 @@ const renderAgentRoute = async ({
|
||||
<Routes>
|
||||
<Route element={<AgentOnboardingRoute />} path="/onboarding/agent" />
|
||||
<Route element={<div>Classic onboarding</div>} path="/onboarding/classic" />
|
||||
<Route element={<div>Common onboarding</div>} path="/onboarding" />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('@lobechat/business-const');
|
||||
vi.doUnmock('@lobechat/const');
|
||||
vi.doUnmock('@/components/Loading/BrandTextLoading');
|
||||
vi.doUnmock('@/features/Onboarding/Agent');
|
||||
vi.doUnmock('@/store/serverConfig');
|
||||
vi.doUnmock('@/store/user');
|
||||
vi.doUnmock('@/store/user/selectors');
|
||||
});
|
||||
|
||||
describe('AgentOnboardingRoute', () => {
|
||||
@@ -66,6 +93,12 @@ describe('AgentOnboardingRoute', () => {
|
||||
expect(screen.getByText('AgentOnboardingRoute')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a loading state before the user state is initialized', async () => {
|
||||
await renderAgentRoute({ enabled: true, isUserStateInit: false });
|
||||
|
||||
expect(screen.getByText('AgentOnboardingRoute')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to classic onboarding when the feature is disabled', async () => {
|
||||
await renderAgentRoute({ enabled: false });
|
||||
|
||||
@@ -77,4 +110,16 @@ describe('AgentOnboardingRoute', () => {
|
||||
|
||||
expect(screen.getByText('Classic onboarding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to /onboarding when the shared prefix is incomplete', async () => {
|
||||
await renderAgentRoute({ commonStepsCompleted: false, enabled: true });
|
||||
|
||||
expect(screen.getByText('Common onboarding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to classic when AGENT_ONBOARDING_ENABLED master switch is off', async () => {
|
||||
await renderAgentRoute({ AGENT_ONBOARDING_ENABLED: false, enabled: true });
|
||||
|
||||
expect(screen.getByText('Classic onboarding')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AGENT_ONBOARDING_ENABLED } from '@lobechat/business-const';
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { memo } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
@@ -5,21 +6,31 @@ import { Navigate } from 'react-router-dom';
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import AgentOnboardingPage from '@/features/Onboarding/Agent';
|
||||
import { useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { onboardingSelectors } from '@/store/user/selectors';
|
||||
|
||||
const AgentOnboardingRoute = memo(() => {
|
||||
const enableAgentOnboarding = useServerConfigStore((s) => s.featureFlags.enableAgentOnboarding);
|
||||
const serverConfigInit = useServerConfigStore((s) => s.serverConfigInit);
|
||||
const isUserStateInit = useUserStore((s) => s.isUserStateInit);
|
||||
const commonStepsCompleted = useUserStore(onboardingSelectors.commonStepsCompleted);
|
||||
|
||||
if (isDesktop) {
|
||||
// Master switch precedes every other gate: when the agent flow is disabled
|
||||
// at build time, this route is unreachable regardless of runtime config.
|
||||
if (!AGENT_ONBOARDING_ENABLED || isDesktop) {
|
||||
return <Navigate replace to="/onboarding/classic" />;
|
||||
}
|
||||
|
||||
if (!serverConfigInit) return <Loading debugId="AgentOnboardingRoute" />;
|
||||
if (!serverConfigInit || !isUserStateInit) return <Loading debugId="AgentOnboardingRoute" />;
|
||||
|
||||
if (!enableAgentOnboarding) {
|
||||
return <Navigate replace to="/onboarding/classic" />;
|
||||
}
|
||||
|
||||
if (!commonStepsCompleted) {
|
||||
return <Navigate replace to="/onboarding" />;
|
||||
}
|
||||
|
||||
return <AgentOnboardingPage />;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AGENT_ONBOARDING_ENABLED } from '@lobechat/business-const';
|
||||
import {
|
||||
BabyIcon,
|
||||
CameraIcon,
|
||||
@@ -18,15 +19,32 @@ import {
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/** Default target when the user opens `/onboarding`. Flip to `'agent'` when agent onboarding is ready to ship as the primary flow. */
|
||||
export type DefaultOnboardingEntryVariant = 'agent' | 'classic';
|
||||
export const DEFAULT_ONBOARDING_ENTRY_VARIANT: DefaultOnboardingEntryVariant = 'classic';
|
||||
export const ONBOARDING_AGENT_PATH = '/onboarding/agent';
|
||||
export const ONBOARDING_CLASSIC_PATH = '/onboarding/classic';
|
||||
|
||||
const resolveDefaultOnboardingPath = (variant: DefaultOnboardingEntryVariant) =>
|
||||
variant === 'agent' ? '/onboarding/agent' : '/onboarding/classic';
|
||||
export type OnboardingBranchPath = typeof ONBOARDING_AGENT_PATH | typeof ONBOARDING_CLASSIC_PATH;
|
||||
|
||||
export const DEFAULT_ONBOARDING_PATH: '/onboarding/agent' | '/onboarding/classic' =
|
||||
resolveDefaultOnboardingPath(DEFAULT_ONBOARDING_ENTRY_VARIANT);
|
||||
interface DeriveOnboardingBranchInput {
|
||||
enableAgentOnboarding: boolean;
|
||||
isDesktop: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which branch the user enters after the shared-prefix steps complete.
|
||||
* `AGENT_ONBOARDING_ENABLED` is the build-time master switch — when it is off,
|
||||
* the agent flow is unreachable regardless of the runtime feature flag.
|
||||
* Desktop and disabled-flag users also land on the classic flow; otherwise
|
||||
* the agent conversational flow is the default.
|
||||
*/
|
||||
export const deriveOnboardingBranchPath = ({
|
||||
enableAgentOnboarding,
|
||||
isDesktop,
|
||||
}: DeriveOnboardingBranchInput): OnboardingBranchPath => {
|
||||
if (!AGENT_ONBOARDING_ENABLED || isDesktop || !enableAgentOnboarding) {
|
||||
return ONBOARDING_CLASSIC_PATH;
|
||||
}
|
||||
return ONBOARDING_AGENT_PATH;
|
||||
};
|
||||
|
||||
/**
|
||||
* Predefined interest areas with icons and translation keys.
|
||||
|
||||
@@ -20,11 +20,17 @@ interface ResponseLanguageStepProps {
|
||||
}
|
||||
|
||||
const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }) => {
|
||||
const { t } = useTranslation(['onboarding', 'common']);
|
||||
const { i18n, t } = useTranslation(['onboarding', 'common']);
|
||||
const switchLocale = useGlobalStore((s) => s.switchLocale);
|
||||
const setSettings = useUserStore((s) => s.setSettings);
|
||||
|
||||
const [value, setValue] = useState<Locales | ''>(() => normalizeLocale(navigator.language));
|
||||
// Mirror i18n's current locale rather than navigator.language. The user may
|
||||
// have already switched language in the previous step (TelemetryStep), so
|
||||
// navigator.language can disagree with what is being rendered. Deriving
|
||||
// straight from i18n keeps the Select in lock-step with the visible UI.
|
||||
const value: Locales = normalizeLocale(
|
||||
i18n.resolvedLanguage || i18n.language || navigator.language,
|
||||
);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
const isNavigatingRef = useRef(false);
|
||||
|
||||
@@ -32,7 +38,7 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
|
||||
if (isNavigatingRef.current) return;
|
||||
isNavigatingRef.current = true;
|
||||
setIsNavigating(true);
|
||||
await setSettings({ general: { responseLanguage: value || '' } });
|
||||
await setSettings({ general: { responseLanguage: value } });
|
||||
await onNext();
|
||||
}, [value, setSettings, onNext]);
|
||||
|
||||
@@ -80,10 +86,7 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
|
||||
width: '100%',
|
||||
}}
|
||||
onChange={(v) => {
|
||||
if (v) {
|
||||
switchLocale(v);
|
||||
setValue(v);
|
||||
}
|
||||
if (v) switchLocale(v);
|
||||
}}
|
||||
/>
|
||||
<SendButton
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
'use client';
|
||||
import CommonOnboardingPage from '@/features/Onboarding/Common';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { DEFAULT_ONBOARDING_PATH } from '@/routes/onboarding/config';
|
||||
|
||||
const OnboardingPage = memo(() => {
|
||||
return <Navigate replace to={DEFAULT_ONBOARDING_PATH} />;
|
||||
});
|
||||
|
||||
OnboardingPage.displayName = 'OnboardingPage';
|
||||
|
||||
export default OnboardingPage;
|
||||
export default CommonOnboardingPage;
|
||||
|
||||
@@ -451,7 +451,7 @@ export const createRuntimeExecutors = (
|
||||
const docService = new AgentDocumentsService(ctx.serverDB, ctx.userId);
|
||||
const personaModel = new UserPersonaModel(ctx.serverDB, ctx.userId);
|
||||
|
||||
const [onboardingState, soulDoc, persona] = await Promise.all([
|
||||
const [onboardingState, soulDoc, persona, userInfo] = await Promise.all([
|
||||
onboardingService.getState(),
|
||||
onboardingService
|
||||
.getInboxAgentId()
|
||||
@@ -466,12 +466,17 @@ export const createRuntimeExecutors = (
|
||||
log('Failed to fetch user persona for onboarding context: %O', error);
|
||||
return null;
|
||||
}),
|
||||
onboardingService.getInitialUserInfo().catch((error) => {
|
||||
log('Failed to fetch initial user info for onboarding context: %O', error);
|
||||
return undefined;
|
||||
}),
|
||||
]);
|
||||
|
||||
onboardingContext = {
|
||||
personaContent: persona?.persona ?? null,
|
||||
phaseGuidance: formatWebOnboardingStateMessage(onboardingState),
|
||||
soulContent: soulDoc?.content ?? null,
|
||||
userInfo,
|
||||
};
|
||||
log('Built onboarding context for agent %s, phase: %s', agentId, onboardingState.phase);
|
||||
} catch (error) {
|
||||
|
||||
@@ -243,19 +243,21 @@ export const userRouter = router({
|
||||
const { UserPersonaModel } = await import('@/database/models/userMemory/persona');
|
||||
const personaModel = new UserPersonaModel(ctx.serverDB, ctx.userId);
|
||||
|
||||
const [state, soulDoc, persona] = await Promise.all([
|
||||
const [state, soulDoc, persona, userInfo] = await Promise.all([
|
||||
onboardingService.getState(),
|
||||
onboardingService
|
||||
.getInboxAgentId()
|
||||
.then((inboxAgentId) => docService.getDocumentByFilename(inboxAgentId, 'SOUL.md'))
|
||||
.catch(() => null),
|
||||
personaModel.getLatestPersonaDocument().catch(() => null),
|
||||
onboardingService.getInitialUserInfo().catch(() => undefined),
|
||||
]);
|
||||
|
||||
return {
|
||||
personaContent: persona?.persona || null,
|
||||
phaseGuidance: formatWebOnboardingStateMessage(state),
|
||||
soulContent: soulDoc?.content || null,
|
||||
userInfo,
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
@@ -187,13 +187,11 @@ describe('OnboardingService', () => {
|
||||
const parsed = SaveUserQuestionInputSchema.parse({
|
||||
fullName: 'Ada Lovelace',
|
||||
interests: ['AI tooling'],
|
||||
responseLanguage: 'en-US',
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
fullName: 'Ada Lovelace',
|
||||
interests: ['AI tooling'],
|
||||
responseLanguage: 'en-US',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,37 +201,45 @@ describe('OnboardingService', () => {
|
||||
|
||||
expect(context).toEqual({
|
||||
finished: false,
|
||||
missingStructuredFields: [
|
||||
'agentName',
|
||||
'agentEmoji',
|
||||
'fullName',
|
||||
'interests',
|
||||
'responseLanguage',
|
||||
],
|
||||
missingStructuredFields: ['agentName', 'agentEmoji', 'fullName', 'interests'],
|
||||
phase: 'agent_identity',
|
||||
topicId: undefined,
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists fullName, interests, and responseLanguage through saveUserQuestion', async () => {
|
||||
it('persists fullName and interests through saveUserQuestion', async () => {
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const result = await service.saveUserQuestion({
|
||||
fullName: 'Ada Lovelace',
|
||||
interests: ['AI tooling'],
|
||||
responseLanguage: 'en-US',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
content: 'Saved full name, interests, and response language.',
|
||||
content: 'Saved full name and interests.',
|
||||
ignoredFields: [],
|
||||
savedFields: ['fullName', 'interests', 'responseLanguage'],
|
||||
savedFields: ['fullName', 'interests'],
|
||||
success: true,
|
||||
unchangedFields: [],
|
||||
});
|
||||
expect(persistedUserState.fullName).toBe('Ada Lovelace');
|
||||
expect(persistedUserState.interests).toEqual(['AI tooling']);
|
||||
expect(persistedUserState.settings.general.responseLanguage).toBe('en-US');
|
||||
});
|
||||
|
||||
it('ignores responseLanguage if the agent still tries to send it', async () => {
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const result = await service.saveUserQuestion({
|
||||
fullName: 'Ada Lovelace',
|
||||
// The schema no longer accepts responseLanguage. Test the reachable
|
||||
// shape — extra props arrive via parseToolArguments and land in
|
||||
// ignoredFields rather than blowing up the call.
|
||||
...({ responseLanguage: 'en-US' } as Record<string, string>),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.savedFields).toEqual(['fullName']);
|
||||
expect(result.ignoredFields).toEqual(['responseLanguage']);
|
||||
expect(persistedUserState.settings.general.responseLanguage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects saveUserQuestion when no supported fields are provided', async () => {
|
||||
@@ -256,7 +262,6 @@ describe('OnboardingService', () => {
|
||||
});
|
||||
persistedUserState.fullName = 'Ada Lovelace';
|
||||
persistedUserState.interests = ['AI tooling'];
|
||||
persistedUserState.settings.general.responseLanguage = 'en-US';
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const context = await service.getState();
|
||||
@@ -266,7 +271,7 @@ describe('OnboardingService', () => {
|
||||
expect(context.finished).toBe(false);
|
||||
});
|
||||
|
||||
it('creates a topic and welcome message during onboarding bootstrap', async () => {
|
||||
it('creates a topic during onboarding bootstrap without persisting a welcome message', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-17T08:00:00.000Z'));
|
||||
|
||||
@@ -276,7 +281,9 @@ describe('OnboardingService', () => {
|
||||
expect(result.topicId).toBe('topic-1');
|
||||
expect(result.agentOnboarding.activeTopicId).toBe('topic-1');
|
||||
expect(result.feedbackSubmitted).toBe(false);
|
||||
expect(mockMessageModel.create).toHaveBeenCalledTimes(1);
|
||||
// The welcome is rendered client-side from i18n, so the bootstrap
|
||||
// must NOT seed an assistant message into the topic.
|
||||
expect(mockMessageModel.create).not.toHaveBeenCalled();
|
||||
expect(persistedTopics['topic-1']?.metadata?.onboardingSession).toEqual({
|
||||
lastActiveAt: '2026-04-17T08:00:00.000Z',
|
||||
phase: 'agent_identity',
|
||||
@@ -486,7 +493,6 @@ describe('OnboardingService', () => {
|
||||
);
|
||||
|
||||
persistedUserState.interests = ['AI tooling'];
|
||||
persistedUserState.settings.general.responseLanguage = 'en-US';
|
||||
persistedUserState.agentOnboarding.discoveryStartUserMessageCount = 0;
|
||||
mockDb.select.mockReturnValue({
|
||||
from: vi.fn(() => ({
|
||||
@@ -516,7 +522,6 @@ describe('OnboardingService', () => {
|
||||
});
|
||||
persistedUserState.fullName = 'Ada Lovelace';
|
||||
persistedUserState.interests = ['AI tooling'];
|
||||
persistedUserState.settings.general.responseLanguage = 'en-US';
|
||||
persistedUserState.agentOnboarding = {
|
||||
activeTopicId: 'topic-1',
|
||||
discoveryStartUserMessageCount: 3,
|
||||
@@ -547,7 +552,6 @@ describe('OnboardingService', () => {
|
||||
});
|
||||
persistedUserState.fullName = 'Ada Lovelace';
|
||||
persistedUserState.interests = ['AI tooling'];
|
||||
persistedUserState.settings.general.responseLanguage = 'en-US';
|
||||
persistedUserState.agentOnboarding = {
|
||||
activeTopicId: 'topic-1',
|
||||
discoveryStartUserMessageCount: 3,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getDocumentTemplate } from '@lobechat/agent-templates';
|
||||
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
|
||||
import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
|
||||
import type { OnboardingUserInfo } from '@lobechat/context-engine';
|
||||
import type {
|
||||
ChatTopicMetadata,
|
||||
MessagePluginItem,
|
||||
@@ -35,14 +36,12 @@ import type { LobeChatDatabase } from '@/database/type';
|
||||
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
|
||||
import { AgentService } from '@/server/services/agent';
|
||||
import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
import { translation } from '@/server/translation';
|
||||
|
||||
const STRUCTURED_FIELD_LABELS: Record<SaveUserQuestionField, string> = {
|
||||
agentEmoji: 'agent emoji',
|
||||
agentName: 'agent name',
|
||||
fullName: 'full name',
|
||||
interests: 'interests',
|
||||
responseLanguage: 'response language',
|
||||
};
|
||||
|
||||
const AGENT_MANAGEMENT_IDENTIFIER = 'lobe-agent-management';
|
||||
@@ -73,6 +72,14 @@ const normalizeTitle = (value: unknown) => {
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeUserInfoField = (value: unknown) => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
const parseToolArguments = (value?: string) => {
|
||||
if (!value) return undefined;
|
||||
|
||||
@@ -253,29 +260,19 @@ export class OnboardingService {
|
||||
return this.userModel.getUserState(KeyVaultsGateKeeper.getUserKeyVaults);
|
||||
};
|
||||
|
||||
private getUserLocale = async () => {
|
||||
getInitialUserInfo = async (): Promise<OnboardingUserInfo | undefined> => {
|
||||
const userState = await this.getUserState();
|
||||
const fullName = normalizeUserInfoField(userState.fullName);
|
||||
const username = normalizeUserInfoField(userState.username);
|
||||
const displayName = fullName || username;
|
||||
|
||||
return userState.settings?.general?.responseLanguage || 'en-US';
|
||||
};
|
||||
if (!displayName && !fullName && !username) return undefined;
|
||||
|
||||
private getWelcomeMessageContent = async () => {
|
||||
const { t } = await translation('onboarding', await this.getUserLocale());
|
||||
|
||||
return t('agent.welcome');
|
||||
};
|
||||
|
||||
private ensureWelcomeMessage = async (topicId: string, agentId: string) => {
|
||||
const existingMessages = await this.messageModel.query({ agentId, pageSize: 1, topicId });
|
||||
|
||||
if (existingMessages.length > 0) return;
|
||||
|
||||
await this.messageModel.create({
|
||||
agentId,
|
||||
content: await this.getWelcomeMessageContent(),
|
||||
role: 'assistant',
|
||||
topicId,
|
||||
});
|
||||
return {
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(fullName ? { fullName } : {}),
|
||||
...(username ? { username } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
private ensureTopic = async (state: UserAgentOnboarding, agentId: string) => {
|
||||
@@ -308,8 +305,6 @@ export class OnboardingService {
|
||||
// User fields
|
||||
if (!userState.fullName?.trim()) missingFields.push('fullName');
|
||||
if (!(userState.interests?.length ?? 0)) missingFields.push('interests');
|
||||
if (!userState.settings?.general?.responseLanguage?.trim())
|
||||
missingFields.push('responseLanguage');
|
||||
|
||||
return missingFields;
|
||||
};
|
||||
@@ -457,11 +452,7 @@ export class OnboardingService {
|
||||
): Promise<OnboardingPhase> => {
|
||||
if (missingStructuredFields.includes('agentName')) return 'agent_identity';
|
||||
if (missingStructuredFields.includes('fullName')) return 'user_identity';
|
||||
if (
|
||||
missingStructuredFields.includes('interests') ||
|
||||
missingStructuredFields.includes('responseLanguage')
|
||||
)
|
||||
return 'discovery';
|
||||
if (missingStructuredFields.includes('interests')) return 'discovery';
|
||||
|
||||
// All fields complete — check pacing gate
|
||||
if (discoveryContext) {
|
||||
@@ -488,8 +479,6 @@ export class OnboardingService {
|
||||
? state
|
||||
: await this.saveState({ ...state, activeTopicId: topicId });
|
||||
|
||||
await this.ensureWelcomeMessage(topicId, builtinAgent.id);
|
||||
|
||||
const topic = await this.topicModel.findById(topicId);
|
||||
const context = await this.getState();
|
||||
|
||||
@@ -628,26 +617,6 @@ export class OnboardingService {
|
||||
await this.userModel.updateUser(userPatch);
|
||||
}
|
||||
|
||||
const responseLanguage =
|
||||
typeof parsed.responseLanguage === 'string' && parsed.responseLanguage.trim()
|
||||
? parsed.responseLanguage.trim()
|
||||
: undefined;
|
||||
if (responseLanguage) {
|
||||
const currentResponseLanguage = userState.settings?.general?.responseLanguage;
|
||||
|
||||
if (responseLanguage === currentResponseLanguage) {
|
||||
unchangedFields.push('responseLanguage');
|
||||
} else {
|
||||
const currentSettings = await this.userModel.getUserSettings();
|
||||
await this.userModel.updateSetting({
|
||||
general: merge(currentSettings?.general || {}, {
|
||||
responseLanguage,
|
||||
}),
|
||||
});
|
||||
savedFields.push('responseLanguage');
|
||||
}
|
||||
}
|
||||
|
||||
// Update inbox agent avatar and title when agent identity fields are provided
|
||||
const agentName =
|
||||
typeof parsed.agentName === 'string' && parsed.agentName.trim()
|
||||
@@ -787,24 +756,22 @@ export class OnboardingService {
|
||||
reset = async () => {
|
||||
const state = defaultAgentOnboardingState();
|
||||
|
||||
// Preserve users.full_name and users.username on reset.
|
||||
// Why: fullName/username are usually seeded from OAuth at signup, and we
|
||||
// surface them to the agent via <user_info> so it can ask "May I call you
|
||||
// <displayName>?" each round. Clearing fullName here would erase the
|
||||
// OAuth-derived hint and force the agent to fall back to an open-ended
|
||||
// name question on every redo.
|
||||
// How to apply: only clear scopes that genuinely belong to the agent
|
||||
// onboarding session (interests, agentOnboarding state, persona doc,
|
||||
// inbox agent title/avatar). responseLanguage is set in the shared-prefix
|
||||
// step and is also out of scope here — use the dedicated reset script
|
||||
// for a full account reset.
|
||||
await this.userModel.updateUser({
|
||||
agentOnboarding: state,
|
||||
fullName: null,
|
||||
interests: [],
|
||||
});
|
||||
|
||||
// Reset responseLanguage in user settings
|
||||
try {
|
||||
const currentSettings = await this.userModel.getUserSettings();
|
||||
await this.userModel.updateSetting({
|
||||
general: merge(currentSettings?.general || {}, {
|
||||
responseLanguage: null,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[OnboardingService] Failed to reset responseLanguage:', error);
|
||||
}
|
||||
|
||||
// Reset persona documents
|
||||
try {
|
||||
await this.db
|
||||
|
||||
@@ -4,14 +4,13 @@ import type {
|
||||
UserAgentOnboardingNode,
|
||||
} from '@lobechat/types';
|
||||
|
||||
import { getScopedPatch, isRecord, normalizeFromSchema, sanitizeText } from './nodeSchema';
|
||||
import { getScopedPatch, isRecord, normalizeFromSchema } from './nodeSchema';
|
||||
|
||||
type OnboardingPatchInput = Record<string, unknown>;
|
||||
type DraftKey = keyof Omit<UserAgentOnboardingDraft, 'responseLanguage'>;
|
||||
type DraftKey = keyof UserAgentOnboardingDraft;
|
||||
|
||||
interface CommitSideEffects {
|
||||
updateInterests?: string[];
|
||||
updateResponseLanguage?: string;
|
||||
updateUserName?: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +19,7 @@ export interface NodeHandler {
|
||||
state: UserAgentOnboarding,
|
||||
draft: UserAgentOnboardingDraft,
|
||||
) => { errorMessage?: string; sideEffects?: CommitSideEffects; success: boolean };
|
||||
readonly draftKey: DraftKey | 'responseLanguage';
|
||||
readonly draftKey: DraftKey;
|
||||
extractDraft: (patch: OnboardingPatchInput) => Partial<UserAgentOnboardingDraft> | undefined;
|
||||
getDraftValue: (draft: UserAgentOnboardingDraft) => unknown;
|
||||
mergeDraft: (draft: UserAgentOnboardingDraft, patch: unknown) => UserAgentOnboardingDraft;
|
||||
@@ -76,27 +75,6 @@ const makeProfileNodeHandler = (
|
||||
},
|
||||
});
|
||||
|
||||
const responseLanguageHandler: NodeHandler = {
|
||||
draftKey: 'responseLanguage',
|
||||
commitToState: (_state, draft) => {
|
||||
if (draft.responseLanguage === undefined) {
|
||||
return { errorMessage: 'Response language has not been captured yet.', success: false };
|
||||
}
|
||||
return {
|
||||
sideEffects: { updateResponseLanguage: draft.responseLanguage },
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
extractDraft: (patch) => {
|
||||
const responseLanguage = sanitizeText(
|
||||
typeof patch.responseLanguage === 'string' ? patch.responseLanguage : undefined,
|
||||
);
|
||||
return responseLanguage ? { responseLanguage } : undefined;
|
||||
},
|
||||
getDraftValue: (draft) => draft.responseLanguage,
|
||||
mergeDraft: (draft, patch) => ({ ...draft, responseLanguage: patch as string }),
|
||||
};
|
||||
|
||||
export const NODE_HANDLERS: Partial<Record<UserAgentOnboardingNode, NodeHandler>> = {
|
||||
agentIdentity: makeProfileNodeHandler('agentIdentity', 'agentIdentity', {
|
||||
key: 'agentIdentity',
|
||||
@@ -105,7 +83,6 @@ export const NODE_HANDLERS: Partial<Record<UserAgentOnboardingNode, NodeHandler>
|
||||
key: 'profile',
|
||||
profileKey: 'painPoints',
|
||||
}),
|
||||
responseLanguage: responseLanguageHandler,
|
||||
userIdentity: makeProfileNodeHandler(
|
||||
'userIdentity',
|
||||
'userIdentity',
|
||||
|
||||
@@ -73,11 +73,4 @@ describe('getNodeDraftState', () => {
|
||||
it('returns undefined for summary node', () => {
|
||||
expect(getNodeDraftState('summary', {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles responseLanguage as scalar', () => {
|
||||
expect(getNodeDraftState('responseLanguage', { responseLanguage: 'zh-CN' })?.status).toBe(
|
||||
'complete',
|
||||
);
|
||||
expect(getNodeDraftState('responseLanguage', {})?.status).toBe('empty');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,12 +166,6 @@ export const getNodeDraftState = (
|
||||
): NodeDraftState | undefined => {
|
||||
if (!node || node === 'summary') return undefined;
|
||||
|
||||
if (node === 'responseLanguage') {
|
||||
return draft.responseLanguage
|
||||
? { status: 'complete' }
|
||||
: { missingFields: ['responseLanguage'], status: 'empty' };
|
||||
}
|
||||
|
||||
const currentDraft = draft[node];
|
||||
|
||||
if (!currentDraft || Object.keys(currentDraft).length === 0) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OnboardingUserInfo } from '@lobechat/context-engine';
|
||||
import { type MarkdownPatchHunk } from '@lobechat/markdown-patch';
|
||||
import { type PartialDeep } from 'type-fest';
|
||||
|
||||
@@ -45,6 +46,7 @@ export class UserService {
|
||||
personaContent: string | null;
|
||||
phaseGuidance: string;
|
||||
soulContent: string | null;
|
||||
userInfo?: OnboardingUserInfo;
|
||||
}> => {
|
||||
return lambdaClient.user.getOnboardingAgentContext.query();
|
||||
};
|
||||
|
||||
@@ -73,7 +73,6 @@ describe('webOnboardingExecutor', () => {
|
||||
items: { type: 'string' },
|
||||
type: 'array',
|
||||
},
|
||||
responseLanguage: { type: 'string' },
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('createCommonSlice', () => {
|
||||
const mockUserState: UserInitializationState = {
|
||||
userId: 'user-id',
|
||||
isOnboard: true,
|
||||
onboarding: { finishedAt: '2024-01-01T00:00:00Z', version: 1 },
|
||||
preference: {
|
||||
telemetry: true,
|
||||
},
|
||||
@@ -184,6 +185,7 @@ describe('createCommonSlice', () => {
|
||||
const mockUserState: UserInitializationState = {
|
||||
userId: 'user-id',
|
||||
isOnboard: true,
|
||||
onboarding: { finishedAt: '2024-01-01T00:00:00Z', version: 1 },
|
||||
preference: undefined as any,
|
||||
settings: null as any,
|
||||
avatar: 'abc',
|
||||
@@ -207,6 +209,29 @@ describe('createCommonSlice', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT auto-fill responseLanguage while onboarding is unfinished', async () => {
|
||||
const { result } = renderHook(() => useUserStore());
|
||||
|
||||
const mockUserState: UserInitializationState = {
|
||||
userId: 'user-id',
|
||||
isOnboard: false,
|
||||
// No onboarding.finishedAt and no agentOnboarding.finishedAt:
|
||||
// user is still in the shared-prefix flow.
|
||||
preference: {} as any,
|
||||
settings: { general: { fontSize: 14 } },
|
||||
};
|
||||
vi.spyOn(userService, 'getUserState').mockResolvedValueOnce(mockUserState);
|
||||
|
||||
renderHook(() => result.current.useInitUserState(true, mockServerConfig), {
|
||||
wrapper: withSWR,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isUserStateInit).toBeTruthy();
|
||||
expect(result.current.settings.general?.responseLanguage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return default preference when local storage is empty', async () => {
|
||||
const { result } = renderHook(() => useUserStore());
|
||||
|
||||
|
||||
@@ -162,7 +162,16 @@ export class CommonActionImpl {
|
||||
}
|
||||
|
||||
// Keep reply language aligned with the browser locale until the user makes a choice.
|
||||
if (!currentGeneralSettings?.responseLanguage && typeof navigator !== 'undefined') {
|
||||
// Only auto-fill once onboarding has finished — otherwise it pre-empts the language
|
||||
// step in the shared-prefix onboarding (commonStepsCompleted derives from this field
|
||||
// being set, and an auto-fill would skip past the user's explicit choice).
|
||||
const hasFinishedOnboarding =
|
||||
!!data.onboarding?.finishedAt || !!data.agentOnboarding?.finishedAt;
|
||||
if (
|
||||
hasFinishedOnboarding &&
|
||||
!currentGeneralSettings?.responseLanguage &&
|
||||
typeof navigator !== 'undefined'
|
||||
) {
|
||||
autoDetectedGeneralConfig.responseLanguage =
|
||||
userGeneralSettingsSelectors.currentResponseLanguage(this.#get());
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ describe('onboardingSelectors', () => {
|
||||
const store = {
|
||||
...initialOnboardingState,
|
||||
localOnboardingStep: undefined,
|
||||
onboarding: { currentStep: 4, version: CURRENT_ONBOARDING_VERSION },
|
||||
onboarding: { currentStep: 2, version: CURRENT_ONBOARDING_VERSION },
|
||||
} as unknown as UserStore;
|
||||
|
||||
expect(onboardingSelectors.currentStep(store)).toBe(4);
|
||||
expect(onboardingSelectors.currentStep(store)).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 1 when both localOnboardingStep and onboarding.currentStep are undefined', () => {
|
||||
@@ -219,4 +219,50 @@ describe('onboardingSelectors', () => {
|
||||
expect(onboardingSelectors.needsOnboarding(store)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commonStepsCompleted', () => {
|
||||
it('returns true when responseLanguage is explicitly stored', () => {
|
||||
const store = {
|
||||
settings: { general: { responseLanguage: 'en-US' } },
|
||||
} as unknown as UserStore;
|
||||
|
||||
expect(onboardingSelectors.commonStepsCompleted(store)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when responseLanguage is empty string (explicit auto-detect choice)', () => {
|
||||
const store = {
|
||||
settings: { general: { responseLanguage: '' } },
|
||||
} as unknown as UserStore;
|
||||
|
||||
expect(onboardingSelectors.commonStepsCompleted(store)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true even when telemetry is missing (telemetry is not part of derivation)', () => {
|
||||
const store = {
|
||||
settings: { general: { responseLanguage: 'en-US' } },
|
||||
} as unknown as UserStore;
|
||||
|
||||
expect(onboardingSelectors.commonStepsCompleted(store)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when responseLanguage is missing', () => {
|
||||
const store = {
|
||||
settings: { general: { telemetry: true } },
|
||||
} as unknown as UserStore;
|
||||
|
||||
expect(onboardingSelectors.commonStepsCompleted(store)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when general is missing entirely', () => {
|
||||
const store = { settings: {} } as unknown as UserStore;
|
||||
|
||||
expect(onboardingSelectors.commonStepsCompleted(store)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a fresh user with empty settings', () => {
|
||||
const store = { settings: undefined } as unknown as UserStore;
|
||||
|
||||
expect(onboardingSelectors.commonStepsCompleted(store)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,20 @@ const needsOnboarding = (s: Pick<UserStore, 'agentOnboarding' | 'onboarding'>) =
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the shared-prefix steps have been completed.
|
||||
*
|
||||
* Only `responseLanguage` is checked: completing the shared prefix is
|
||||
* marked by writing it on step 2. Telemetry can't be used as a signal
|
||||
* because `setSettings` strips fields that match the default
|
||||
* (DEFAULT_COMMON_SETTINGS.telemetry === true), so a user who keeps
|
||||
* the default-on choice never persists telemetry to s.settings.
|
||||
*/
|
||||
const commonStepsCompleted = (s: Pick<UserStore, 'settings'>) =>
|
||||
s.settings?.general?.responseLanguage !== undefined;
|
||||
|
||||
export const onboardingSelectors = {
|
||||
commonStepsCompleted,
|
||||
currentStep,
|
||||
finishedAt,
|
||||
isFinished,
|
||||
|
||||
@@ -7,15 +7,15 @@ import { describe, expect, it } from 'vitest';
|
||||
describe('web onboarding tool result helpers', () => {
|
||||
it('keeps tool action content message-first', () => {
|
||||
const result = createWebOnboardingToolResult({
|
||||
content: 'Saved interests and response language.',
|
||||
savedFields: ['interests', 'responseLanguage'],
|
||||
content: 'Saved full name and interests.',
|
||||
savedFields: ['fullName', 'interests'],
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(result.content).toBe('Saved interests and response language.');
|
||||
expect(result.content).toBe('Saved full name and interests.');
|
||||
expect(result.state).toEqual({
|
||||
isError: false,
|
||||
savedFields: ['interests', 'responseLanguage'],
|
||||
savedFields: ['fullName', 'interests'],
|
||||
success: true,
|
||||
});
|
||||
expect(result.content.trim().startsWith('{')).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user