feat(onboarding): enhance agent onboarding with new question renderer and refined interaction logic

- Introduced a new `QuestionRendererView` component to streamline the rendering of onboarding questions.
- Refactored the `QuestionRenderer` to utilize a runtime hook for improved state management and separation of concerns.
- Updated the onboarding context to fallback to stored questions when the current question is empty, enhancing user experience.
- Simplified the onboarding API by removing unnecessary read token requirements from various endpoints.
- Added tests to validate the new question rendering logic and ensure proper functionality.

This update aims to create a more efficient and user-friendly onboarding experience by improving the question handling and rendering process.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-03-24 19:52:15 +08:00
parent c4f208acf3
commit 31f327e70f
21 changed files with 869 additions and 1125 deletions

View File

@@ -199,6 +199,7 @@
"@langchain/community": "^0.3.59",
"@lexical/utils": "^0.39.0",
"@lobechat/agent-runtime": "workspace:*",
"@lobechat/builtin-agent-onboarding": "workspace:*",
"@lobechat/builtin-agents": "workspace:*",
"@lobechat/builtin-skills": "workspace:*",
"@lobechat/builtin-tool-agent-builder": "workspace:*",

View File

@@ -8,7 +8,8 @@ import type {
} from '@lobechat/types';
import { Button, Flexbox, Input, Select, Text } from '@lobehub/ui';
import { Input as AntdInput } from 'antd';
import type { type ChangeEvent, memo, ReactNode, useEffect, useMemo, useState } from 'react';
import type { ChangeEvent, ReactNode } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
type FormValue = string | string[];
@@ -248,6 +249,7 @@ const QuestionForm = memo<{
if (!message) return;
await onSendMessage(message);
onDismissNode?.(question.node);
};

View File

@@ -1,177 +1,55 @@
const systemRoleTemplate = `
You are the dedicated web onboarding agent.
This is not open-ended chatting. This is a guided first conversation with a strict job:
- complete onboarding
- move one node at a time
- keep the conversation natural
- leave the user with a sharp, useful understanding of what you can do for them
This is a guided first conversation with one job: complete onboarding and leave the user with a clear sense of how you can help.
Style:
- Sound like a real person, not support copy.
- Be concise. Usually 1 short paragraph. Sometimes 2.
- Be concise.
- Ask one focused question at a time.
- Cut filler, praise, and generic enthusiasm.
- Have some personality, but do not let personality slow the flow.
- Keep the tone natural.
- Avoid filler and generic enthusiasm.
Language:
- The preferred reply language is mandatory.
- Every visible reply must be entirely in that language unless the user explicitly switches languages.
- Keep tool names and schema keys in English only inside tool calls.
- Before sending a visible reply, check that the reply, suggested choices, and any visible labels all follow the required language.
Non-negotiable protocol:
Operational rules:
1. The first onboarding tool call of every turn must be getOnboardingState.
2. getOnboardingState is the only source of truth for:
- activeNode
- activeNodeDraftState
- committed values
- completedNodes
- control
- draft
- currentQuestion
3. Only use these onboarding tools to move the flow:
- getOnboardingState
- askUserQuestion
- saveAnswer
- completeCurrentStep
- returnToOnboarding
- finishOnboarding
4. Never say something was saved, committed, or finished unless the tool call succeeded.
5. Never call a later node just because the user mentioned later information early. Finish the active node first.
6. Never browse, research, search, use memory, or solve unrelated tasks during onboarding.
7. Never expose internal tool names or node names unless it is genuinely necessary.
2. activeNode is the only step you may act on.
3. Use exactly one primary onboarding action for the active node:
- saveAnswer when the user already gave a clear answer for the active node
- askUserQuestion when the active node still needs an answer and currentQuestion is missing, weak, or stale
- completeCurrentStep only when the existing draft for the active node is already complete and the user is only confirming it
- returnToOnboarding when the user goes off-topic
- finishOnboarding only from summary after the user confirms the summary
4. If currentQuestion is missing or weak and the active node still needs an answer, call askUserQuestion before any visible reply.
5. Never skip ahead to a later node.
6. Never claim something was saved or completed unless the tool call succeeded.
7. If a tool call fails, stay on the active node and recover from that result only.
Field contract:
- Only send node-scoped fields that the service actually accepts.
- Never invent keys such as alias, role, styleLabel, personalityTraits, or any other schema not listed below.
- Required means the step cannot be committed until those fields exist in draft.
- agentIdentity:
- allowed: emoji, name, nature, vibe
- required: emoji, name, nature, vibe
- this node is about the assistant identity only, not the user
- userIdentity:
- allowed: name, professionalRole, domainExpertise, summary
- required: summary
- workStyle:
- allowed: communicationStyle, decisionMaking, socialMode, thinkingPreferences, workStyle, summary
- required: summary
- workContext:
- allowed: activeProjects, currentFocus, interests, thisQuarter, thisWeek, tools, summary
- required: summary
- painPoints:
- allowed: blockedBy, frustrations, noTimeFor, summary
- required: summary
- responseLanguage:
- allowed: responseLanguage
- required: responseLanguage
- proSettings:
- allowed: model, provider
- required for a full save: model, provider
- summary:
- do not use saveAnswer for summary
- summarize committed data, then ask only whether the summary is accurate
- after a light confirmation, call finishOnboarding immediately
- do not ask what to do next until onboarding is already finished
Read-token contract:
- Every onboarding tool call after getOnboardingState must include the latest control.readToken.
- That token is single-use for the current state snapshot.
- If you need another onboarding tool after saveAnswer, completeCurrentStep, returnToOnboarding, or a failed action, call getOnboardingState again first and use the new control.readToken.
Turn algorithm:
1. Call getOnboardingState.
2. Read activeNode, activeNodeDraftState, currentQuestion, draft, committed values, and control.
3. Choose exactly one primary action for the active node:
- askUserQuestion:
Use this when currentQuestion is missing, weak, stale, mismatched to the active node, or not the best way to get the answer.
- saveAnswer:
Use this when the user has already given a clear answer for the active node.
Batch multiple consecutive nodes only when the user clearly answered them in one turn.
- completeCurrentStep:
Use this only when activeNodeDraftState.status is complete and the user is clearly confirming the existing draft.
- returnToOnboarding:
Use this when the user goes off-topic and you need to pull the conversation back.
- finishOnboarding:
Use this only after the summary is shown and the user clearly confirms it.
4. After the tool result, send the smallest useful visible reply.
5. If a tool result gives you a directive, follow it literally.
6. If any onboarding tool returns success: false:
- do not pretend progress happened
- do not move to another node
- use the error content, instruction, activeNode, and activeNodeDraftState as recovery signals
- if the failure happened after saveAnswer, completeCurrentStep, returnToOnboarding, or finishOnboarding, read onboarding state again before the next visible reply
- then ask only for the missing information required to finish the current node
7. A short confirmation such as "ok", "好的", or "继续" is not enough to complete a node unless the current draft is already complete.
8. If a successful tool result says the state advanced and the next node has no currentQuestion, call getOnboardingState again and then askUserQuestion for the new active node before any visible reply.
How to use askUserQuestion:
- Define one current question, not a questionnaire.
- The question should help the user answer with minimal effort.
- Prefer stronger answer surfaces when they reduce typing:
- button_group for a few natural answer choices
- form when several tightly related fields belong together
- select for a single constrained choice
- info only when the UI itself is doing most of the work
- composer_prefill only as a weak temporary fallback
- A button_group must contain executable choices. Do not create inert buttons.
- Choice labels should look like things the user would naturally say.
- Prefer payload.message for conversational choices and payload.patch only when direct structured submission is clearly better.
What counts as a weak question:
- generic composer_prefill
- info-only question with no clear action
- vague text like "continue this step"
- choices that are not actionable
Question strategy by node:
- agentIdentity:
- You just came online. You do not know your own name, nature, vibe, or emoji yet.
- If the user gives weak guidance like "hi", "whatever", or "up to you", offer concrete options or make a coherent proposal yourself and ask for light confirmation.
- Even if the user also introduces themselves, do not leave this node early.
- Never ask for the user's name or profession in this node.
- userIdentity:
- Figure out who they are professionally.
- Capture enough detail to avoid vague labels.
- Prefer producing a concise summary plus any available structured name, professionalRole, or domainExpertise.
- workStyle:
- Figure out how they think, decide, communicate, and work with others.
- The commit anchor is summary. Extra fields are optional support.
- workContext:
- Figure out what they are focused on now, what tools they use, and what domains they care about.
- The commit anchor is summary. Extra fields are optional support.
- painPoints:
- Figure out where friction actually lives.
- Look for bottlenecks, neglected work, repeated frustration, and unmet needs.
- The commit anchor is summary. Extra fields are optional support.
- responseLanguage:
- Capture the default reply language clearly.
- proSettings:
- Capture a model/provider only if the user gives a real preference or is ready to continue.
- Do not invent setup work.
- summary:
- Describe the user like you are telling a friend about someone interesting you just met.
- Do not output a sterile checklist.
- Then give 3-5 concrete ways you can help next.
- End by asking only whether the summary is accurate.
- If the user gives a light confirmation such as "ok", "可以", "确认", "就这样", or equivalent agreement, treat that as confirmation and finish onboarding.
- Do not ask where to start inside onboarding. That belongs to the first post-onboarding turn.
Questioning:
- Ask only what is needed to finish the active node.
- Prefer one actionable question over a questionnaire.
- Keep visible choices natural and executable.
Boundaries:
- Do not turn onboarding into open-ended interviewing, therapy, career coaching, or generic profile building.
- Do not ask broad discovery questions unless they directly help finish the active node.
- If the user goes off-topic, acknowledge briefly, call returnToOnboarding, and pull back to the active question.
- Do not browse, research, or solve unrelated tasks during onboarding.
- Do not expose internal node names unless necessary.
Completion:
Summary:
- Only the summary node can end onboarding.
- Do not call finishOnboarding before the summary is shown and confirmed.
- At summary, describe the user like a person, not a checklist.
- Give 3-5 concrete ways you can help next.
- Ask only whether the summary is accurate.
- After a light confirmation, call finishOnboarding.
`.trim();
export const createSystemRole = (userLocale?: string) =>
[
systemRoleTemplate,
userLocale
? `Preferred reply language: ${userLocale}. This is mandatory. Every visible reply, question, and visible choice label must be entirely in ${userLocale} unless the user explicitly asks to switch. If any sentence drifts into another language, rewrite it before sending.`
? `Preferred reply language: ${userLocale}. This is mandatory. Every visible reply, question, and visible choice label must be entirely in ${userLocale} unless the user explicitly asks to switch.`
: '',
]
.filter(Boolean)

View File

@@ -1,76 +1,35 @@
export const toolSystemPrompt = `
You are the only toolset allowed to manage the web onboarding flow.
You manage the web onboarding flow.
Protocol:
Operational rules:
1. The first onboarding tool call of every turn must be getOnboardingState.
2. Treat getOnboardingState as the only source of truth for:
- activeNode
- activeNodeDraftState
- committed values
- completedNodes
- control
- draft
- currentQuestion
3. Only advance the flow through:
2. Treat activeNode as the only step you may act on.
3. Use only:
- askUserQuestion
- saveAnswer
- completeCurrentStep
- returnToOnboarding
- finishOnboarding
4. Prefer saveAnswer when the user already gave a clear answer for the active node.
5. Use completeCurrentStep only when activeNodeDraftState.status is complete and the user is just confirming the existing draft.
6. Never call a later node before the active step is complete, even if the user mentioned later-step information early.
7. Never finish onboarding before the summary step.
8. Do not claim onboarding data was saved unless the tool call succeeded.
9. If any onboarding tool returns success: false, do not advance. Use the returned instruction, activeNode, and activeNodeDraftState to recover, then ask only for the missing information on the current node.
10. Every onboarding tool call after getOnboardingState must include the latest control.readToken.
11. If you need another onboarding tool after saveAnswer, completeCurrentStep, returnToOnboarding, or finishOnboarding, call getOnboardingState again first and use the new control.readToken.
4. Use exactly one primary action for the active node:
- saveAnswer when the user already answered the active node
- askUserQuestion when the active node still needs an answer and currentQuestion is missing, weak, or stale
- completeCurrentStep only for an already complete active-node draft
- returnToOnboarding for off-topic turns
- finishOnboarding only after the summary is shown and confirmed
5. If currentQuestion is missing or weak and the active node still needs an answer, call askUserQuestion before any visible reply.
6. saveAnswer accepts only fields for the active node.
7. If saveAnswer makes the active node complete, it will commit and advance automatically.
8. If a tool call fails, do not advance. Recover on the current node.
9. Never finish onboarding before summary.
Accepted fields by node:
- agentIdentity: emoji, name, nature, vibe
- userIdentity: name, professionalRole, domainExpertise, summary
- workStyle: communicationStyle, decisionMaking, socialMode, thinkingPreferences, workStyle, summary
- workContext: activeProjects, currentFocus, interests, thisQuarter, thisWeek, tools, summary
- painPoints: blockedBy, frustrations, noTimeFor, summary
- responseLanguage: responseLanguage
- proSettings: model, provider
- Never invent fields outside this contract.
Question surfaces:
- Ask one focused question for the active node.
- Keep choices actionable.
- Prefer natural reply options.
askUserQuestion rules:
1. askUserQuestion defines one current question for the active node.
2. Prefer the strongest useful answer surface:
- button_group
- form
- select
- info
- composer_prefill
3. button_group choices must be executable.
4. Prefer payload.message for natural conversational answers.
5. Use payload.patch only when direct structured submission is clearly better.
6. Avoid weak fallback questions when a better question can be generated now.
saveAnswer rules:
1. saveAnswer accepts batch updates.
2. Batch only when the user clearly answered multiple consecutive nodes in one turn.
3. Keep updates in node order.
4. saveAnswer patches must use only the accepted fields for that node.
5. agentIdentity is about the assistant identity only. Do not ask for or save the user's name, role, or profile in agentIdentity.
6. If saveAnswer or completeCurrentStep advances the state and the new active node has no currentQuestion, call getOnboardingState again and then askUserQuestion for that new node before replying.
Node order:
- agentIdentity
- userIdentity
- workStyle
- workContext
- painPoints
- responseLanguage
- proSettings
- summary
Summary rule:
- At summary, describe the user like a person, not a report.
- Then give 3-5 concrete service suggestions.
- Then ask only whether the summary is accurate.
- If the user gives a light confirmation, finish onboarding immediately.
- Do not ask where to start until onboarding is already complete.
Summary:
- Summarize the user like a person.
- Give 3-5 concrete ways you can help next.
- Ask only whether the summary is accurate.
- After a light confirmation, call finishOnboarding.
`.trim();

View File

@@ -7,7 +7,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
api: [
{
description:
'Read the current onboarding state, including the active step, committed values, saved draft, any currently stored question surface, and the control metadata required for the next onboarding tool call.',
'Read the current onboarding state, including the active step, committed values, saved draft, any currently stored question surface, and lightweight control metadata.',
name: WebOnboardingApiName.getOnboardingState,
parameters: {
properties: {},
@@ -34,11 +34,6 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
],
type: 'string',
},
readToken: {
description:
'The latest single-use read token returned by getOnboardingState.control.readToken.',
type: 'string',
},
question: {
properties: {
choices: {
@@ -145,7 +140,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
type: 'object',
},
},
required: ['node', 'question', 'readToken'],
required: ['node', 'question'],
type: 'object',
},
},
@@ -155,11 +150,6 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
name: WebOnboardingApiName.saveAnswer,
parameters: {
properties: {
readToken: {
description:
'The latest single-use read token returned by getOnboardingState.control.readToken.',
type: 'string',
},
updates: {
items: {
properties: {
@@ -187,7 +177,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
type: 'array',
},
},
required: ['readToken', 'updates'],
required: ['updates'],
type: 'object',
},
},
@@ -210,13 +200,8 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
],
type: 'string',
},
readToken: {
description:
'The latest single-use read token returned by getOnboardingState.control.readToken.',
type: 'string',
},
},
required: ['node', 'readToken'],
required: ['node'],
type: 'object',
},
},
@@ -226,14 +211,8 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
name: WebOnboardingApiName.returnToOnboarding,
parameters: {
properties: {
readToken: {
description:
'The latest single-use read token returned by getOnboardingState.control.readToken.',
type: 'string',
},
reason: { type: 'string' },
},
required: ['readToken'],
type: 'object',
},
},
@@ -242,14 +221,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
'Finish the onboarding flow from the summary step and mirror the legacy onboarding completion flag.',
name: WebOnboardingApiName.finishOnboarding,
parameters: {
properties: {
readToken: {
description:
'The latest single-use read token returned by getOnboardingState.control.readToken.',
type: 'string',
},
},
required: ['readToken'],
properties: {},
type: 'object',
},
},

View File

@@ -123,18 +123,11 @@ export interface UserAgentOnboardingQuestionSurface {
updatedAt: string;
}
export interface UserAgentOnboardingExecutionGuard {
issuedAt: string;
readToken: string;
}
export interface UserAgentOnboardingControl {
allowedTools: string[];
canCompleteCurrentStep: boolean;
canFinish: boolean;
missingFields: string[];
readToken?: string;
readTokenRequired: boolean;
}
export interface UserAgentOnboardingContext {
@@ -168,7 +161,6 @@ export interface UserAgentOnboarding {
agentIdentity?: UserOnboardingAgentIdentity;
completedNodes?: UserAgentOnboardingNode[];
draft?: UserAgentOnboardingDraft;
executionGuard?: UserAgentOnboardingExecutionGuard;
finishedAt?: string;
profile?: UserOnboardingProfile;
questionSurface?: UserAgentOnboardingQuestionSurface;
@@ -289,17 +281,11 @@ const UserAgentOnboardingQuestionSurfaceSchema = z.object({
updatedAt: z.string(),
});
const UserAgentOnboardingExecutionGuardSchema = z.object({
issuedAt: z.string(),
readToken: z.string(),
});
export const UserAgentOnboardingSchema = z.object({
activeTopicId: z.string().optional(),
agentIdentity: UserOnboardingAgentIdentitySchema.optional(),
completedNodes: z.array(UserAgentOnboardingNodeSchema).optional(),
draft: UserAgentOnboardingDraftSchema.optional(),
executionGuard: UserAgentOnboardingExecutionGuardSchema.optional(),
finishedAt: z.string().optional(),
profile: z
.object({

View File

@@ -0,0 +1,127 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import QuestionRenderer from './QuestionRenderer';
const sendMessage = vi.fn();
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@/utils/env', () => ({ isDev: false }));
vi.mock('@/const/onboarding', () => ({
ONBOARDING_PRODUCTION_DEFAULT_MODEL: {
model: 'gpt-4.1-mini',
provider: 'openai',
},
}));
vi.mock('@/features/Conversation', () => ({
useConversationStore: (selector: (state: any) => unknown) =>
selector({
sendMessage,
}),
}));
vi.mock('@/features/Conversation/store', () => ({
messageStateSelectors: {
isInputLoading: () => false,
},
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (selector: (state: any) => unknown) =>
selector({
switchLocale: vi.fn(),
}),
}));
vi.mock('@/store/serverConfig', () => ({
serverConfigSelectors: {
enableKlavis: () => false,
},
useServerConfigStore: (selector: (state: any) => unknown) => selector({}),
}));
vi.mock('@/store/user', () => ({
useUserStore: (selector: (state: any) => unknown) =>
selector({
updateGeneralConfig: vi.fn(),
updateDefaultModel: vi.fn(),
}),
}));
vi.mock('@/store/user/selectors', () => ({
settingsSelectors: {
currentSettings: () => ({
defaultAgent: {},
general: {},
}),
},
userGeneralSettingsSelectors: {
currentResponseLanguage: () => 'en-US',
},
}));
vi.mock('@/components/EmojiPicker', () => ({
default: () => <div data-testid="emoji-picker" />,
}));
vi.mock('@/features/ModelSelect', () => ({
default: () => <div data-testid="model-select" />,
}));
vi.mock('@/routes/onboarding/components/KlavisServerList', () => ({
default: () => <div data-testid="klavis-list" />,
}));
describe('QuestionRenderer runtime', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('dismisses the inline question without waiting for the full send lifecycle', async () => {
const onDismissNode = vi.fn();
sendMessage.mockImplementation(
() =>
new Promise(() => {
// Intentionally unresolved to simulate a long-running streaming lifecycle.
}),
);
render(
<QuestionRenderer
currentQuestion={{
choices: [
{
id: 'preset',
label: 'Warm + curious',
payload: {
kind: 'message',
message: 'hello from hint',
},
style: 'primary',
},
],
id: 'question-1',
mode: 'button_group',
node: 'agentIdentity',
prompt: 'Pick one',
}}
onDismissNode={onDismissNode}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Warm + curious' }));
await waitFor(() => {
expect(sendMessage).toHaveBeenCalledWith({ message: 'hello from hint' });
expect(onDismissNode).toHaveBeenCalledWith('agentIdentity');
});
});
});

View File

@@ -1,85 +1,20 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import QuestionRenderer from './QuestionRenderer';
import QuestionRendererView from './QuestionRendererView';
const sendMessage = vi.fn();
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const baseProps = {
fallbackQuestionDescription: 'agent.telemetryHint',
fallbackTextFieldLabel: 'agent.telemetryHint',
fallbackTextFieldPlaceholder: 'agent.telemetryHint',
nextLabel: 'next',
onSendMessage: sendMessage,
submitLabel: 'next',
} as const;
vi.mock('@/utils/env', () => ({ isDev: false }));
vi.mock('@/const/onboarding', () => ({
ONBOARDING_PRODUCTION_DEFAULT_MODEL: {
model: 'gpt-4.1-mini',
provider: 'openai',
},
}));
vi.mock('@/features/Conversation', () => ({
useConversationStore: (selector: (state: any) => unknown) =>
selector({
sendMessage,
}),
}));
vi.mock('@/features/Conversation/store', () => ({
messageStateSelectors: {
isInputLoading: () => false,
},
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (selector: (state: any) => unknown) =>
selector({
switchLocale: vi.fn(),
}),
}));
vi.mock('@/store/serverConfig', () => ({
serverConfigSelectors: {
enableKlavis: () => false,
},
useServerConfigStore: (selector: (state: any) => unknown) => selector({}),
}));
vi.mock('@/store/user', () => ({
useUserStore: (selector: (state: any) => unknown) =>
selector({
updateGeneralConfig: vi.fn(),
updateDefaultModel: vi.fn(),
}),
}));
vi.mock('@/store/user/selectors', () => ({
settingsSelectors: {
currentSettings: () => ({
defaultAgent: {},
general: {},
}),
},
userGeneralSettingsSelectors: {
currentResponseLanguage: () => 'en-US',
},
}));
vi.mock('@/components/EmojiPicker', () => ({
default: () => <div data-testid="emoji-picker" />,
}));
vi.mock('@/features/ModelSelect', () => ({
default: () => <div data-testid="model-select" />,
}));
vi.mock('@/routes/onboarding/components/KlavisServerList', () => ({
default: () => <div data-testid="klavis-list" />,
}));
describe('QuestionRenderer', () => {
describe('QuestionRendererView', () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -88,7 +23,8 @@ describe('QuestionRenderer', () => {
const onDismissNode = vi.fn();
render(
<QuestionRenderer
<QuestionRendererView
{...baseProps}
currentQuestion={{
choices: [
{
@@ -112,7 +48,7 @@ describe('QuestionRenderer', () => {
fireEvent.click(screen.getByRole('button', { name: 'Warm + curious' }));
expect(sendMessage).toHaveBeenCalledWith({ message: 'hello from hint' });
expect(sendMessage).toHaveBeenCalledWith('hello from hint');
await waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(onDismissNode).toHaveBeenCalledWith('agentIdentity');
@@ -121,7 +57,8 @@ describe('QuestionRenderer', () => {
it('falls back to sending the action label when a message button has no payload', async () => {
render(
<QuestionRenderer
<QuestionRendererView
{...baseProps}
currentQuestion={{
choices: [
{
@@ -140,13 +77,14 @@ describe('QuestionRenderer', () => {
fireEvent.click(screen.getByRole('button', { name: 'AI 产品开发者' }));
expect(sendMessage).toHaveBeenCalledWith({ message: 'AI 产品开发者' });
expect(sendMessage).toHaveBeenCalledWith('AI 产品开发者');
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
});
it('falls back to the action label for patch-style actions', async () => {
render(
<QuestionRenderer
<QuestionRendererView
{...baseProps}
currentQuestion={{
choices: [
{
@@ -171,14 +109,13 @@ describe('QuestionRenderer', () => {
fireEvent.click(screen.getByRole('button', { name: 'Use Chinese' }));
expect(sendMessage).toHaveBeenCalledWith({
message: 'Use Chinese',
});
expect(sendMessage).toHaveBeenCalledWith('Use Chinese');
});
it('falls back to a text form when a button group has no choices', async () => {
render(
<QuestionRenderer
<QuestionRendererView
{...baseProps}
currentQuestion={{
id: 'agent-identity-missing-choices',
mode: 'button_group',
@@ -193,16 +130,17 @@ describe('QuestionRenderer', () => {
});
fireEvent.click(screen.getByRole('button', { name: 'next' }));
expect(sendMessage).toHaveBeenCalledWith({
message: ['Q: agent.telemetryHint', 'A: 叫 shishi风格偏直接。'].join('\n'),
});
expect(sendMessage).toHaveBeenCalledWith(
['Q: agent.telemetryHint', 'A: 叫 shishi风格偏直接。'].join('\n'),
);
});
it('formats form submissions as question-answer text', async () => {
const onDismissNode = vi.fn();
render(
<QuestionRenderer
<QuestionRendererView
{...baseProps}
currentQuestion={{
fields: [
{
@@ -237,9 +175,9 @@ describe('QuestionRenderer', () => {
});
fireEvent.click(screen.getByRole('button', { name: 'next' }));
expect(sendMessage).toHaveBeenCalledWith({
message: ['Q: Role', 'A: Independent developer', '', 'Q: Name', 'A: Ada'].join('\n'),
});
expect(sendMessage).toHaveBeenCalledWith(
['Q: Role', 'A: Independent developer', '', 'Q: Name', 'A: Ada'].join('\n'),
);
await waitFor(() => {
expect(onDismissNode).toHaveBeenCalledWith('userIdentity');
});
@@ -247,7 +185,8 @@ describe('QuestionRenderer', () => {
it('submits the form when pressing Enter in a text input', async () => {
render(
<QuestionRenderer
<QuestionRendererView
{...baseProps}
currentQuestion={{
fields: [
{
@@ -274,14 +213,13 @@ describe('QuestionRenderer', () => {
key: 'Enter',
});
expect(sendMessage).toHaveBeenCalledWith({
message: ['Q: Role', 'A: Independent developer'].join('\n'),
});
expect(sendMessage).toHaveBeenCalledWith(['Q: Role', 'A: Independent developer'].join('\n'));
});
it('formats select submissions as question-answer text', async () => {
render(
<QuestionRenderer
<QuestionRendererView
{...baseProps}
currentQuestion={{
fields: [
{
@@ -305,8 +243,42 @@ describe('QuestionRenderer', () => {
fireEvent.click(screen.getByRole('button', { name: 'next' }));
expect(sendMessage).toHaveBeenCalledWith({
message: ['Q: Response language', 'A: English'].join('\n'),
expect(sendMessage).toHaveBeenCalledWith(['Q: Response language', 'A: English'].join('\n'));
});
it('normalizes select questions with choices into button groups', async () => {
render(
<QuestionRendererView
{...baseProps}
responseLanguageOptions={[{ label: '简体中文', value: 'zh-CN' }]}
currentQuestion={{
choices: [
{
id: 'emoji_lightning',
label: '⚡ 闪电 — 快速、高效',
payload: {
kind: 'patch',
patch: {
emoji: '⚡',
},
},
},
],
id: 'agent_emoji_select',
mode: 'select',
node: 'agentIdentity',
prompt: '最后一步——选个 emoji 作为我的标志。',
}}
/>,
);
expect(screen.getByRole('button', { name: '⚡ 闪电 — 快速、高效' })).toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '⚡ 闪电 — 快速、高效' }));
await waitFor(() => {
expect(sendMessage).toHaveBeenCalledWith('⚡ 闪电 — 快速、高效');
});
});
});

View File

@@ -1,27 +1,10 @@
'use client';
import {
QuestionRenderer as BuiltinAgentQuestionRenderer,
type QuestionRendererProps as BuiltinAgentQuestionRendererProps,
} from '@lobechat/builtin-agent-onboarding/client';
import type { UserAgentOnboardingNode, UserAgentOnboardingQuestion } from '@lobechat/types';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { memo } from 'react';
import EmojiPicker from '@/components/EmojiPicker';
import { useConversationStore } from '@/features/Conversation';
import { messageStateSelectors } from '@/features/Conversation/store';
import ModelSelect from '@/features/ModelSelect';
import { localeOptions } from '@/locales/resources';
import KlavisServerList from '@/routes/onboarding/components/KlavisServerList';
import { useGlobalStore } from '@/store/global';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { settingsSelectors, userGeneralSettingsSelectors } from '@/store/user/selectors';
import { type LocaleMode } from '@/types/locale';
import { isDev } from '@/utils/env';
import { ONBOARDING_PRODUCTION_DEFAULT_MODEL } from '../../../const/onboarding';
import { useQuestionRendererRuntime } from './questionRendererRuntime';
import QuestionRendererView from './QuestionRendererView';
interface QuestionRendererProps {
currentQuestion: UserAgentOnboardingQuestion;
@@ -29,98 +12,15 @@ interface QuestionRendererProps {
}
const QuestionRenderer = memo<QuestionRendererProps>(({ currentQuestion, onDismissNode }) => {
const { t } = useTranslation('onboarding');
const loading = useConversationStore(messageStateSelectors.isInputLoading);
const sendMessage = useConversationStore((s) => s.sendMessage);
const switchLocale = useGlobalStore((s) => s.switchLocale);
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
const currentResponseLanguage = useUserStore(
userGeneralSettingsSelectors.currentResponseLanguage,
const runtime = useQuestionRendererRuntime();
return (
<QuestionRendererView
currentQuestion={currentQuestion}
onDismissNode={onDismissNode}
{...runtime}
/>
);
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
const defaultAgentConfig = useUserStore(
(s) => settingsSelectors.currentSettings(s).defaultAgent?.config,
);
const updateDefaultModel = useUserStore((s) => s.updateDefaultModel);
const defaultModelConfig = isDev ? defaultAgentConfig : ONBOARDING_PRODUCTION_DEFAULT_MODEL;
const resolvedQuestion = useMemo<UserAgentOnboardingQuestion>(() => {
if (
currentQuestion.mode !== 'button_group' ||
(currentQuestion.choices && currentQuestion.choices.length > 0)
) {
return currentQuestion;
}
return {
...currentQuestion,
description: currentQuestion.description ?? t('agent.telemetryHint'),
fields: [
{
key: 'answer',
kind: 'text',
label: t('agent.telemetryHint'),
placeholder: t('agent.telemetryHint'),
},
],
mode: 'form',
};
}, [currentQuestion, t]);
const props: BuiltinAgentQuestionRendererProps = {
currentQuestion: resolvedQuestion,
currentResponseLanguage,
defaultModelConfig,
enableKlavis,
fixedModelLabel: t('proSettings.model.fixed', {
model: ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
provider: ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
}),
isDev,
loading,
nextLabel: t('next'),
onBeforeInfoContinue: async () => {
if (!isDev) {
await updateDefaultModel(
ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
);
}
},
onChangeDefaultModel: (model, provider) => {
void updateDefaultModel(model, provider);
},
onChangeResponseLanguage: (value) => {
switchLocale(value as LocaleMode);
void updateGeneralConfig({ responseLanguage: value });
},
onDismissNode,
onSendMessage: async (message) => {
await sendMessage({ message });
},
renderEmojiPicker: ({ onChange, value }) => <EmojiPicker value={value} onChange={onChange} />,
renderKlavisList: () => <KlavisServerList />,
renderModelSelect: ({ onChange, value }) => (
<ModelSelect
showAbility={false}
size={'large'}
style={{ width: '100%' }}
value={
value?.model
? {
model: value.model,
...(value.provider ? { provider: value.provider } : {}),
}
: undefined
}
onChange={onChange}
/>
),
responseLanguageOptions: localeOptions,
submitLabel: t('next'),
};
return <BuiltinAgentQuestionRenderer {...props} />;
});
QuestionRenderer.displayName = 'QuestionRenderer';

View File

@@ -0,0 +1,46 @@
'use client';
import {
QuestionRenderer as BuiltinAgentQuestionRenderer,
type QuestionRendererProps as BuiltinAgentQuestionRendererProps,
} from '@lobechat/builtin-agent-onboarding/client';
import { memo, useMemo } from 'react';
import { normalizeQuestionRendererQuestion } from './questionRendererSchema';
export interface QuestionRendererViewProps extends BuiltinAgentQuestionRendererProps {
fallbackQuestionDescription: string;
fallbackTextFieldLabel: string;
fallbackTextFieldPlaceholder: string;
}
const QuestionRendererView = memo<QuestionRendererViewProps>(
({
currentQuestion,
fallbackQuestionDescription,
fallbackTextFieldLabel,
fallbackTextFieldPlaceholder,
...builtinProps
}) => {
const resolvedQuestion = useMemo(
() =>
normalizeQuestionRendererQuestion(currentQuestion, {
description: fallbackQuestionDescription,
label: fallbackTextFieldLabel,
placeholder: fallbackTextFieldPlaceholder,
}),
[
currentQuestion,
fallbackQuestionDescription,
fallbackTextFieldLabel,
fallbackTextFieldPlaceholder,
],
);
return <BuiltinAgentQuestionRenderer currentQuestion={resolvedQuestion} {...builtinProps} />;
},
);
QuestionRendererView.displayName = 'QuestionRendererView';
export default QuestionRendererView;

View File

@@ -53,4 +53,68 @@ describe('resolveAgentOnboardingContext', () => {
topicId: 'topic-bootstrap',
});
});
it('falls back to stored questionSurface when bootstrap currentQuestion is empty', () => {
const result = resolveAgentOnboardingContext({
bootstrapContext: {
agentOnboarding: {
activeTopicId: 'topic-bootstrap',
completedNodes: [],
version: 1,
},
context: {
currentQuestion: undefined,
},
topicId: 'topic-bootstrap',
},
storedAgentOnboarding: {
completedNodes: [],
questionSurface: {
node: 'agentIdentity',
question: {
id: 'agent_identity_001',
mode: 'button_group',
node: 'agentIdentity',
prompt: '贾维斯的气质应该是什么样的?',
},
updatedAt: '2026-03-24T00:00:00.000Z',
},
version: 1,
},
});
expect(result).toEqual({
currentQuestion: {
id: 'agent_identity_001',
mode: 'button_group',
node: 'agentIdentity',
prompt: '贾维斯的气质应该是什么样的?',
},
topicId: 'topic-bootstrap',
});
});
it('does not surface questionSurface for completed nodes', () => {
const result = resolveAgentOnboardingContext({
storedAgentOnboarding: {
completedNodes: ['agentIdentity'],
questionSurface: {
node: 'agentIdentity',
question: {
id: 'agent_identity_001',
mode: 'button_group',
node: 'agentIdentity',
prompt: '贾维斯的气质应该是什么样的?',
},
updatedAt: '2026-03-24T00:00:00.000Z',
},
version: 1,
},
});
expect(result).toEqual({
currentQuestion: undefined,
topicId: undefined,
});
});
});

View File

@@ -13,10 +13,22 @@ interface ResolveAgentOnboardingContextParams {
storedAgentOnboarding?: UserAgentOnboarding;
}
const resolveQuestionFromSurface = (state?: UserAgentOnboarding) => {
const questionSurface = state?.questionSurface;
if (!questionSurface) return undefined;
if (state?.completedNodes?.includes(questionSurface.node)) return undefined;
return questionSurface.question;
};
export const resolveAgentOnboardingContext = ({
bootstrapContext,
storedAgentOnboarding,
}: ResolveAgentOnboardingContextParams) => ({
currentQuestion: bootstrapContext?.context.currentQuestion,
currentQuestion:
bootstrapContext?.context.currentQuestion ||
resolveQuestionFromSurface(storedAgentOnboarding) ||
resolveQuestionFromSurface(bootstrapContext?.agentOnboarding),
topicId: bootstrapContext?.topicId ?? storedAgentOnboarding?.activeTopicId,
});

View File

@@ -0,0 +1,117 @@
'use client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import EmojiPicker from '@/components/EmojiPicker';
import { useConversationStore } from '@/features/Conversation';
import { messageStateSelectors } from '@/features/Conversation/store';
import ModelSelect from '@/features/ModelSelect';
import { localeOptions } from '@/locales/resources';
import KlavisServerList from '@/routes/onboarding/components/KlavisServerList';
import { useGlobalStore } from '@/store/global';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { settingsSelectors, userGeneralSettingsSelectors } from '@/store/user/selectors';
import { type LocaleMode } from '@/types/locale';
import { isDev } from '@/utils/env';
import { ONBOARDING_PRODUCTION_DEFAULT_MODEL } from '../../../const/onboarding';
import type { QuestionRendererViewProps } from './QuestionRendererView';
export interface QuestionRendererRuntimeProps extends Omit<
QuestionRendererViewProps,
'currentQuestion' | 'onDismissNode'
> {}
export const useQuestionRendererRuntime = (): QuestionRendererRuntimeProps => {
const { t } = useTranslation('onboarding');
const loading = useConversationStore(messageStateSelectors.isInputLoading);
const sendMessage = useConversationStore((s) => s.sendMessage);
const switchLocale = useGlobalStore((s) => s.switchLocale);
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
const currentResponseLanguage = useUserStore(
userGeneralSettingsSelectors.currentResponseLanguage,
);
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
const defaultAgentConfig = useUserStore(
(s) => settingsSelectors.currentSettings(s).defaultAgent?.config,
);
const updateDefaultModel = useUserStore((s) => s.updateDefaultModel);
const defaultModelConfig = isDev ? defaultAgentConfig : ONBOARDING_PRODUCTION_DEFAULT_MODEL;
const telemetryHint = t('agent.telemetryHint');
const nextLabel = t('next');
return useMemo(
() => ({
currentResponseLanguage,
defaultModelConfig,
enableKlavis,
fallbackQuestionDescription: telemetryHint,
fallbackTextFieldLabel: telemetryHint,
fallbackTextFieldPlaceholder: telemetryHint,
fixedModelLabel: t('proSettings.model.fixed', {
model: ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
provider: ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
}),
isDev,
loading,
nextLabel,
onBeforeInfoContinue: async () => {
if (!isDev) {
await updateDefaultModel(
ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
);
}
},
onChangeDefaultModel: (model: string, provider: string) => {
void updateDefaultModel(model, provider);
},
onChangeResponseLanguage: (value: string) => {
switchLocale(value as LocaleMode);
void updateGeneralConfig({ responseLanguage: value });
},
onSendMessage: async (message: string) => {
// Dismiss the inline onboarding widget immediately after dispatch.
// The full chat send lifecycle also awaits runtime streaming, which is too late
// for this UI pattern because the question should disappear once submitted.
void sendMessage({ message }).catch(console.error);
},
renderEmojiPicker: ({ onChange, value }) => <EmojiPicker value={value} onChange={onChange} />,
renderKlavisList: () => <KlavisServerList />,
renderModelSelect: ({ onChange, value }) => (
<ModelSelect
showAbility={false}
size={'large'}
style={{ width: '100%' }}
value={
value?.model
? {
model: value.model,
...(value.provider ? { provider: value.provider } : {}),
}
: undefined
}
onChange={onChange}
/>
),
responseLanguageOptions: localeOptions,
submitLabel: nextLabel,
}),
[
currentResponseLanguage,
defaultModelConfig,
enableKlavis,
loading,
nextLabel,
sendMessage,
switchLocale,
t,
telemetryHint,
updateDefaultModel,
updateGeneralConfig,
],
);
};

View File

@@ -0,0 +1,42 @@
import type { UserAgentOnboardingQuestion } from '@lobechat/types';
interface QuestionRendererFallbackCopy {
description: string;
label: string;
placeholder: string;
}
export const normalizeQuestionRendererQuestion = (
question: UserAgentOnboardingQuestion,
fallbackCopy: QuestionRendererFallbackCopy,
): UserAgentOnboardingQuestion => {
if (
question.mode === 'select' &&
(!question.fields || question.fields.length === 0) &&
question.choices &&
question.choices.length > 0
) {
return {
...question,
mode: 'button_group',
};
}
if (question.mode !== 'button_group' || (question.choices && question.choices.length > 0)) {
return question;
}
return {
...question,
description: question.description ?? fallbackCopy.description,
fields: [
{
key: 'answer',
kind: 'text',
label: fallbackCopy.label,
placeholder: fallbackCopy.placeholder,
},
],
mode: 'form',
};
};

View File

@@ -204,13 +204,12 @@ export const userRouter = router({
getOnboardingState: userProcedure.query(async ({ ctx }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.getState({ issueReadToken: true });
return onboardingService.getState();
}),
saveOnboardingAnswer: userProcedure
.input(
z.object({
readToken: z.string().min(1),
updates: z.array(UserAgentOnboardingUpdateSchema).min(1),
}),
)
@@ -225,7 +224,6 @@ export const userRouter = router({
z.object({
node: UserAgentOnboardingNodeSchema,
question: UserAgentOnboardingQuestionDraftSchema,
readToken: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -238,39 +236,32 @@ export const userRouter = router({
.input(
z.object({
node: UserAgentOnboardingNodeSchema,
readToken: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.completeCurrentStep(input.node, input.readToken);
return onboardingService.completeCurrentStep(input.node);
}),
returnToOnboarding: userProcedure
.input(
z.object({
readToken: z.string().min(1),
reason: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.returnToOnboarding(input.readToken, input.reason);
return onboardingService.returnToOnboarding(input.reason);
}),
finishOnboarding: userProcedure
.input(
z.object({
readToken: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
finishOnboarding: userProcedure.input(z.object({})).mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
void input;
return onboardingService.finishOnboarding(input.readToken);
}),
return onboardingService.finishOnboarding();
}),
resetAgentOnboarding: userProcedure.mutation(async ({ ctx }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);

View File

@@ -105,16 +105,14 @@ describe('OnboardingService', () => {
vi.mocked(AgentService).mockImplementation(() => mockAgentService as any);
});
const issueReadToken = async (service: OnboardingService) => {
const context = await service.getState({ issueReadToken: true });
return context.control.readToken!;
};
it('resets legacy onboarding nodes to the new conversational flow', async () => {
it('resets legacy onboarding nodes to the conversational flow', async () => {
persistedUserState.agentOnboarding = {
completedNodes: ['telemetry', 'fullName'],
currentNode: 'fullName',
executionGuard: {
issuedAt: '2026-03-24T00:00:00.000Z',
readToken: 'legacy-token',
},
version: CURRENT_ONBOARDING_VERSION,
};
@@ -126,7 +124,7 @@ describe('OnboardingService', () => {
expect(context.committed.agentIdentity).toBeUndefined();
});
it('issues control metadata and a read token for tool execution state reads', async () => {
it('returns lightweight control metadata without read tokens', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
@@ -134,43 +132,13 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const context = await service.getState({ issueReadToken: true });
const context = await service.getState();
expect(context.control.readTokenRequired).toBe(true);
expect(context.control.readToken).toBeTruthy();
expect(context.control.allowedTools).toContain('getOnboardingState');
expect(context.control.allowedTools).toContain('askUserQuestion');
expect(context.control.allowedTools).toContain('saveAnswer');
});
it('rejects onboarding actions when the latest state read token is missing or stale', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
version: CURRENT_ONBOARDING_VERSION,
};
const service = new OnboardingService(mockDb, userId);
const result = await service.saveAnswer({
readToken: 'stale-token',
updates: [
{
node: 'agentIdentity',
patch: {
emoji: '🦞',
name: 'Lobster',
nature: 'direct',
vibe: 'steady',
},
},
],
});
expect(result.success).toBe(false);
expect(result.error?.code).toBe('STATE_READ_REQUIRED');
expect(result.instruction).toContain(
'Call getOnboardingState immediately before any other onboarding tool.',
);
expect('readToken' in context.control).toBe(false);
expect('readTokenRequired' in context.control).toBe(false);
});
it('commits agent identity and advances to user identity', async () => {
@@ -182,7 +150,6 @@ describe('OnboardingService', () => {
const service = new OnboardingService(mockDb, userId);
const result = await service.saveAnswer({
readToken: await issueReadToken(service),
updates: [
{
node: 'agentIdentity',
@@ -199,6 +166,7 @@ describe('OnboardingService', () => {
});
expect(result.success).toBe(true);
expect(result.activeNode).toBe('userIdentity');
expect(persistedUserState.agentOnboarding.agentIdentity).toEqual({
emoji: '🫖',
name: '小七',
@@ -208,26 +176,7 @@ describe('OnboardingService', () => {
expect(persistedUserState.agentOnboarding.completedNodes).toEqual(['agentIdentity']);
});
it('preserves node-scoped flat patch fields in the update schema', () => {
const parsed = UserAgentOnboardingUpdateSchema.parse({
node: 'agentIdentity',
patch: {
emoji: '🐍',
name: '小齐',
nature: 'Sharp, playful AI sidekick with insights',
vibe: 'Serpent-like: sharp, witty, and insightful',
},
});
expect(parsed.patch).toEqual({
emoji: '🐍',
name: '小齐',
nature: 'Sharp, playful AI sidekick with insights',
vibe: 'Serpent-like: sharp, witty, and insightful',
});
});
it('accepts flat agent identity payloads scoped by node', async () => {
it('stores partial drafts and reports missing required fields', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
@@ -236,40 +185,6 @@ describe('OnboardingService', () => {
const service = new OnboardingService(mockDb, userId);
const result = await service.saveAnswer({
readToken: await issueReadToken(service),
updates: [
{
node: 'agentIdentity',
patch: {
emoji: '🐍',
name: '小齐',
nature: 'Sharp, playful AI sidekick with insights',
vibe: 'Serpent-like: sharp, witty, and insightful',
},
},
],
});
expect(result.success).toBe(true);
expect(persistedUserState.agentOnboarding.agentIdentity).toEqual({
emoji: '🐍',
name: '小齐',
nature: 'Sharp, playful AI sidekick with insights',
vibe: 'Serpent-like: sharp, witty, and insightful',
});
expect(persistedUserState.agentOnboarding.completedNodes).toEqual(['agentIdentity']);
});
it('stores partial agent identity drafts without committing the node', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
version: CURRENT_ONBOARDING_VERSION,
};
const service = new OnboardingService(mockDb, userId);
const result = await service.saveAnswer({
readToken: await issueReadToken(service),
updates: [
{
node: 'agentIdentity',
@@ -286,14 +201,13 @@ describe('OnboardingService', () => {
missingFields: ['emoji', 'name', 'nature'],
status: 'partial',
});
expect(result.content).toContain('Saved a partial draft');
expect(persistedUserState.agentOnboarding.draft.agentIdentity).toEqual({
vibe: '活泼',
});
expect(persistedUserState.agentOnboarding.completedNodes).toEqual([]);
});
it('preserves later-step drafts when the model calls userIdentity too early', async () => {
it('rejects writes to a non-active node and does not preserve later drafts', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
@@ -302,7 +216,6 @@ describe('OnboardingService', () => {
const service = new OnboardingService(mockDb, userId);
const result = await service.saveAnswer({
readToken: await issueReadToken(service),
updates: [
{
node: 'userIdentity',
@@ -319,71 +232,14 @@ describe('OnboardingService', () => {
});
expect(result.success).toBe(false);
expect(result.content).toContain('saved the later-step draft');
expect(result.content).toContain('Stay on agentIdentity');
expect(result.error?.code).toBe('NODE_MISMATCH');
expect(result.activeNode).toBe('agentIdentity');
expect(result.requestedNode).toBe('userIdentity');
expect(result.instruction).toContain('Do not call userIdentity yet');
expect(result.currentQuestion).toBeUndefined();
expect(result.mismatch).toBe(true);
expect(result.savedDraftFields).toEqual(['userIdentity']);
expect(persistedUserState.agentOnboarding.draft.userIdentity).toEqual({
domainExpertise: 'AI application development',
name: 'Ada',
professionalRole: 'Engineer',
summary: 'Ada builds AI application features and infra.',
});
expect(persistedUserState.agentOnboarding.draft.userIdentity).toBeUndefined();
});
it('does not derive a current question from onboarding state without a stored question surface', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
currentNode: 'agentIdentity',
draft: {
responseLanguage: 'zh-CN',
},
version: CURRENT_ONBOARDING_VERSION,
};
const service = new OnboardingService(mockDb, userId);
const context = await service.getState();
expect(context.activeNode).toBe('agentIdentity');
expect(context.currentQuestion).toBeUndefined();
});
it('does not derive later questions from completed nodes without a stored question surface', async () => {
persistedUserState.agentOnboarding = {
completedNodes: ['agentIdentity', 'userIdentity', 'workStyle', 'workContext', 'painPoints'],
currentNode: 'agentIdentity',
draft: {
responseLanguage: 'zh-CN',
},
version: CURRENT_ONBOARDING_VERSION,
};
const service = new OnboardingService(mockDb, userId);
const context = await service.getState();
expect(context.activeNode).toBe('responseLanguage');
expect(context.currentQuestion).toBeUndefined();
});
it('returns no current question when the active node has no stored question surface', async () => {
persistedUserState.agentOnboarding = {
completedNodes: ['agentIdentity'],
draft: {},
version: CURRENT_ONBOARDING_VERSION,
};
const service = new OnboardingService(mockDb, userId);
const context = await service.getState();
expect(context.activeNode).toBe('userIdentity');
expect(context.currentQuestion).toBeUndefined();
});
it('supports batch updates across consecutive onboarding nodes', async () => {
it('supports sequential batch updates across consecutive nodes', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
@@ -392,28 +248,21 @@ describe('OnboardingService', () => {
const service = new OnboardingService(mockDb, userId);
const result = await service.saveAnswer({
readToken: await issueReadToken(service),
updates: [
{
node: 'agentIdentity',
patch: {
agentIdentity: {
emoji: '🦊',
name: '小七',
nature: 'a fox-like AI collaborator',
vibe: 'sharp and warm',
},
emoji: '🦊',
name: '小七',
nature: 'a fox-like AI collaborator',
vibe: 'sharp and warm',
},
},
{
node: 'userIdentity',
patch: {
userIdentity: {
domainExpertise: 'AI application development',
name: 'Ada',
professionalRole: 'Engineer',
summary: 'Ada builds AI application features and infra.',
},
summary: 'Ada builds AI application features and infra.',
professionalRole: 'Engineer',
},
},
],
@@ -422,9 +271,10 @@ describe('OnboardingService', () => {
expect(result.success).toBe(true);
expect(result.processedNodes).toEqual(['agentIdentity', 'userIdentity']);
expect(result.activeNode).toBe('workStyle');
expect(result.currentQuestion).toBeUndefined();
expect(persistedUserState.agentOnboarding.agentIdentity?.name).toBe('小七');
expect(persistedUserState.agentOnboarding.profile?.identity?.name).toBe('Ada');
expect(persistedUserState.agentOnboarding.profile?.identity).toEqual({
professionalRole: 'Engineer',
summary: 'Ada builds AI application features and infra.',
});
});
it('stores the current question for the active onboarding node', async () => {
@@ -453,7 +303,6 @@ describe('OnboardingService', () => {
mode: 'button_group',
prompt: 'AI-generated preset',
},
readToken: await issueReadToken(service),
});
expect(result.success).toBe(true);
@@ -465,17 +314,9 @@ describe('OnboardingService', () => {
node: 'agentIdentity',
}),
);
expect(persistedUserState.agentOnboarding.questionSurface).toEqual({
node: 'agentIdentity',
question: expect.objectContaining({
id: 'agent-identity-ai-preset',
node: 'agentIdentity',
}),
updatedAt: expect.any(String),
});
});
it('clears the current question after the onboarding node advances', async () => {
it('clears the current question after the active node advances', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
@@ -494,17 +335,14 @@ describe('OnboardingService', () => {
const service = new OnboardingService(mockDb, userId);
await service.saveAnswer({
readToken: await issueReadToken(service),
updates: [
{
node: 'agentIdentity',
patch: {
agentIdentity: {
emoji: '🫖',
name: '小七',
nature: 'an odd little AI housemate',
vibe: 'warm and sharp',
},
emoji: '🫖',
name: '小七',
nature: 'an odd little AI housemate',
vibe: 'warm and sharp',
},
},
],
@@ -513,74 +351,36 @@ describe('OnboardingService', () => {
const context = await service.getState();
expect(context.activeNode).toBe('userIdentity');
expect(persistedUserState.agentOnboarding.questionSurface).toBeUndefined();
expect(context.currentQuestion).toBeUndefined();
expect(persistedUserState.agentOnboarding.questionSurface).toBeUndefined();
});
it('surfaces a committed profile across the new onboarding dimensions', async () => {
it('commits an already complete draft through completeCurrentStep', async () => {
persistedUserState.agentOnboarding = {
agentIdentity: {
emoji: '🫖',
name: '小七',
nature: 'an odd little AI housemate',
vibe: 'warm and sharp',
},
completedNodes: ['agentIdentity', 'userIdentity', 'workStyle', 'workContext', 'painPoints'],
draft: {},
profile: {
currentFocus: 'shipping a 0-1 B2B data platform this quarter',
identity: {
domainExpertise: 'B2B SaaS',
name: 'Ada',
professionalRole: 'Product Manager',
summary: 'Ada is a B2B SaaS PM building a data platform from 0 to 1.',
},
interests: ['product strategy', 'data platforms'],
painPoints: {
blockedBy: ['cross-team alignment'],
frustrations: ['spec churn'],
noTimeFor: ['user research synthesis'],
summary:
'Execution gets dragged down by alignment overhead and constant requirement churn.',
},
workContext: {
activeProjects: ['data platform 0-1'],
currentFocus: 'define the MVP and prove adoption',
interests: ['product strategy', 'data platforms'],
summary:
'She is focused on MVP definition, adoption, and the operating rhythm around the launch.',
thisQuarter: 'launch the first usable version',
thisWeek: 'close scope and unblock engineering',
tools: ['Notion', 'Figma', 'SQL'],
},
completedNodes: ['agentIdentity', 'userIdentity'],
draft: {
workStyle: {
communicationStyle: 'direct',
decisionMaking: 'data-informed but fast',
socialMode: 'collaborative',
summary: 'She likes clear trade-offs, quick synthesis, and direct communication.',
thinkingPreferences: 'structured',
workStyle: 'fast-moving',
summary: 'Prefers direct communication and quick synthesis.',
},
},
version: CURRENT_ONBOARDING_VERSION,
};
persistedUserState.fullName = 'Ada';
persistedUserState.interests = ['product strategy', 'data platforms'];
const service = new OnboardingService(mockDb, userId);
const context = await service.getState();
const committedAgentIdentity = context.committed.agentIdentity as any;
const committedProfile = context.committed.profile as any;
const result = await service.completeCurrentStep('workStyle');
expect(committedAgentIdentity?.name).toBe('小七');
expect(context.currentQuestion).toBeUndefined();
expect(committedProfile?.identity?.professionalRole).toBe('Product Manager');
expect(committedProfile?.workStyle?.decisionMaking).toBe('data-informed but fast');
expect(committedProfile?.workContext?.tools).toEqual(['Notion', 'Figma', 'SQL']);
expect(committedProfile?.painPoints?.blockedBy).toEqual(['cross-team alignment']);
expect(result.success).toBe(true);
expect(persistedUserState.agentOnboarding.completedNodes).toEqual([
'agentIdentity',
'userIdentity',
'workStyle',
]);
expect(persistedUserState.agentOnboarding.profile?.workStyle).toEqual({
summary: 'Prefers direct communication and quick synthesis.',
});
});
it('allows finish when summary is the derived active step even if legacy currentNode is stale', async () => {
it('rejects saveAnswer on summary and only finishes from summary', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [
'agentIdentity',
@@ -591,19 +391,47 @@ describe('OnboardingService', () => {
'responseLanguage',
'proSettings',
],
currentNode: 'agentIdentity',
draft: {},
version: CURRENT_ONBOARDING_VERSION,
};
const service = new OnboardingService(mockDb, userId);
const result = await service.finishOnboarding(await issueReadToken(service));
const saveResult = await service.saveAnswer({
updates: [
{
node: 'summary',
patch: {},
},
],
});
const finishResult = await service.finishOnboarding();
expect(result.success).toBe(true);
expect(saveResult.success).toBe(false);
expect(saveResult.error?.code).toBe('INCOMPLETE_NODE_DATA');
expect(finishResult.success).toBe(true);
expect(persistedUserState.agentOnboarding.finishedAt).toBeTruthy();
expect(persistedUserState.agentOnboarding.completedNodes).toContain('summary');
});
it('preserves flat node-scoped patch fields in the update schema', () => {
const parsed = UserAgentOnboardingUpdateSchema.parse({
node: 'agentIdentity',
patch: {
emoji: '🐍',
name: '小齐',
nature: 'Sharp, playful AI sidekick with insights',
vibe: 'Serpent-like: sharp, witty, and insightful',
},
});
expect(parsed.patch).toEqual({
emoji: '🐍',
name: '小齐',
nature: 'Sharp, playful AI sidekick with insights',
vibe: 'Serpent-like: sharp, witty, and insightful',
});
});
it('creates a persisted welcome message for a new onboarding topic', async () => {
persistedUserState.agentOnboarding = {
completedNodes: ['agentIdentity', 'userIdentity'],
@@ -620,11 +448,6 @@ describe('OnboardingService', () => {
title: 'Onboarding',
trigger: 'chat',
});
expect(mockMessageModel.query).toHaveBeenCalledWith({
agentId: 'builtin-agent-1',
pageSize: 1,
topicId: 'topic-1',
});
expect(mockMessageModel.create).toHaveBeenCalledWith({
agentId: 'builtin-agent-1',
content: expect.stringContaining('Onboarding'),

View File

@@ -1,5 +1,3 @@
import { randomUUID } from 'node:crypto';
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
import type {
@@ -132,6 +130,45 @@ interface OnboardingNodeDraftState {
status: 'complete' | 'empty' | 'partial';
}
interface OnboardingError {
code: 'INCOMPLETE_NODE_DATA' | 'INVALID_PATCH_SHAPE' | 'NODE_MISMATCH' | 'ONBOARDING_COMPLETE';
message: string;
}
interface CommitStepResult {
content: string;
control: UserAgentOnboardingControl;
currentQuestion?: UserAgentOnboardingQuestion;
success: boolean;
}
interface ProposePatchResult {
activeNode?: UserAgentOnboardingNode;
activeNodeDraftState?: OnboardingNodeDraftState;
committedValue?: unknown;
content: string;
control?: UserAgentOnboardingControl;
currentQuestion?: UserAgentOnboardingQuestion;
draft: UserAgentOnboardingDraft;
error?: OnboardingError;
mismatch?: boolean;
nextAction: 'ask' | 'commit' | 'confirm';
processedNodes?: UserAgentOnboardingNode[];
requestedNode?: UserAgentOnboardingNode;
success: boolean;
}
interface AskQuestionResult {
activeNode?: UserAgentOnboardingNode;
content: string;
control?: UserAgentOnboardingControl;
currentQuestion?: UserAgentOnboardingQuestion;
mismatch?: boolean;
requestedNode?: UserAgentOnboardingNode;
storedQuestionId?: string;
success: boolean;
}
const getScopedPatch = (node: UserAgentOnboardingNode, patch: OnboardingPatchInput) => {
const nestedKey = node === 'proSettings' ? 'defaultModel' : node;
const nestedPatch = isRecord(patch[nestedKey]) ? patch[nestedKey] : undefined;
@@ -526,45 +563,14 @@ const getNodeDraftState = (
};
};
const getNodeRecoveryInstruction = (node: UserAgentOnboardingNode) => {
switch (node) {
case 'agentIdentity': {
return 'Stay on agentIdentity. Do not call userIdentity yet. Ask the user to define your name, nature, vibe, and emoji, or offer concrete defaults and confirm one.';
}
case 'userIdentity': {
return 'Stay on userIdentity. Ask who the user is, what they do, and what domain they work in. Capture a concise summary plus any available name, role, or domain expertise.';
}
case 'workStyle': {
return 'Stay on workStyle. Ask how the user thinks, decides, communicates, and works with others. Capture a concise summary of their style.';
}
case 'workContext': {
return 'Stay on workContext. Ask about current focus, active projects, interests, tools, and near-term priorities. Capture a concise summary plus any concrete details.';
}
case 'painPoints': {
return 'Stay on painPoints. Ask what is blocked, frustrating, repetitive, or never gets enough time. Capture the actual friction, not generic preferences.';
}
case 'responseLanguage': {
return 'Stay on responseLanguage. Ask which language the user wants as the default reply language.';
}
case 'proSettings': {
return 'Stay on proSettings. Capture a default model/provider only if the user gives one or says they are ready to continue.';
}
case 'summary': {
return 'Stay on summary. Summarize the committed setup, ask only whether the summary is accurate, and call finishOnboarding immediately after a light confirmation. Do not ask what to do next inside onboarding.';
}
}
};
const buildOnboardingControl = ({
activeNode,
activeNodeDraftState,
currentQuestion,
readToken,
}: {
activeNode?: UserAgentOnboardingNode;
activeNodeDraftState?: OnboardingNodeDraftState;
currentQuestion?: UserAgentOnboardingQuestion;
readToken?: string;
}): UserAgentOnboardingControl => {
const missingFields = activeNodeDraftState?.missingFields ?? [];
const canCompleteCurrentStep =
@@ -574,14 +580,11 @@ const buildOnboardingControl = ({
if (activeNode) {
if (activeNode === 'summary') {
allowedTools.push(currentQuestion ? 'finishOnboarding' : 'askUserQuestion');
} else {
allowedTools.push('saveAnswer');
allowedTools.push('askUserQuestion');
if (canCompleteCurrentStep) {
allowedTools.push('completeCurrentStep');
}
if (currentQuestion) allowedTools.push('finishOnboarding');
} else {
allowedTools.push('saveAnswer', 'askUserQuestion');
if (canCompleteCurrentStep) allowedTools.push('completeCurrentStep');
}
}
@@ -594,8 +597,6 @@ const buildOnboardingControl = ({
canCompleteCurrentStep,
canFinish,
missingFields,
...(readToken ? { readToken } : {}),
readTokenRequired: true,
};
};
@@ -610,46 +611,6 @@ const attachNodeToQuestion = (
question: UserAgentOnboardingQuestionDraft,
): UserAgentOnboardingQuestion => ({ ...question, node });
interface ProposePatchResult {
activeNode?: UserAgentOnboardingNode;
activeNodeDraftState?: OnboardingNodeDraftState;
committedValue?: unknown;
content: string;
control?: UserAgentOnboardingControl;
currentQuestion?: UserAgentOnboardingQuestion;
draft: UserAgentOnboardingDraft;
error?: {
code:
| 'INCOMPLETE_NODE_DATA'
| 'INVALID_PATCH_SHAPE'
| 'NODE_MISMATCH'
| 'ONBOARDING_COMPLETE'
| 'STATE_READ_REQUIRED';
message: string;
};
instruction?: string;
mismatch?: boolean;
nextAction: 'ask' | 'commit' | 'confirm';
processedNodes?: UserAgentOnboardingNode[];
requestedNode?: UserAgentOnboardingNode;
requiresReadBeforeNextTool?: boolean;
savedDraftFields?: (keyof UserAgentOnboardingDraft)[];
success: boolean;
}
interface AskQuestionResult {
activeNode?: UserAgentOnboardingNode;
content: string;
control?: UserAgentOnboardingControl;
currentQuestion?: UserAgentOnboardingQuestion;
instruction?: string;
mismatch?: boolean;
requestedNode?: UserAgentOnboardingNode;
requiresReadBeforeNextTool?: boolean;
storedQuestionId?: string;
success: boolean;
}
export class OnboardingService {
private readonly agentService: AgentService;
private readonly messageModel: MessageModel;
@@ -679,18 +640,20 @@ export class OnboardingService {
const mergedState = merge(defaultAgentOnboardingState(), state ?? {}) as UserAgentOnboarding & {
currentNode?: UserAgentOnboardingNode;
executionGuard?: unknown;
};
const { currentNode: legacyCurrentNode, ...nextState } = mergedState;
const {
currentNode: legacyCurrentNode,
executionGuard: legacyExecutionGuard,
...nextState
} = mergedState;
void legacyCurrentNode;
void legacyExecutionGuard;
return {
...nextState,
completedNodes: dedupeNodes((nextState.completedNodes ?? []).filter(isValidNode)),
draft: nextState.draft ?? {},
executionGuard:
nextState.executionGuard?.readToken && nextState.executionGuard?.issuedAt
? nextState.executionGuard
: undefined,
questionSurface:
nextState.questionSurface?.node &&
getActiveNode(nextState) === nextState.questionSurface.node
@@ -781,64 +744,9 @@ export class OnboardingService {
};
};
private issueReadToken = async (state: UserAgentOnboarding) => {
const readToken = randomUUID();
const nextState = await this.saveState({
...state,
executionGuard: {
issuedAt: new Date().toISOString(),
readToken,
},
});
return { readToken, state: nextState };
};
private consumeReadToken = async (readToken?: string) => {
const state = await this.ensurePersistedState();
if (
!readToken ||
!state.executionGuard?.readToken ||
state.executionGuard.readToken !== readToken
) {
return {
error: {
code: 'STATE_READ_REQUIRED' as const,
message:
'Call getOnboardingState immediately before any other onboarding tool. The read token is missing, stale, or already used.',
},
instruction:
'Call getOnboardingState immediately before any other onboarding tool. Then use the latest control.readToken in the next tool call.',
state,
success: false,
};
}
const { executionGuard: _executionGuard, ...restState } = state;
void _executionGuard;
await this.saveState(restState);
return {
state: restState,
success: true,
};
};
getState = async (options?: {
issueReadToken?: boolean;
}): Promise<UserAgentOnboardingContext> => {
getState = async (): Promise<UserAgentOnboardingContext> => {
const userState = await this.getUserState();
let state = this.ensureState(userState.agentOnboarding);
let readToken: string | undefined;
if (options?.issueReadToken && !state.finishedAt) {
const issued = await this.issueReadToken(state);
readToken = issued.readToken;
state = issued.state;
}
const state = this.ensureState(userState.agentOnboarding);
const committed = {
agentIdentity: state.agentIdentity,
defaultModel: userState.settings.defaultAgent?.config
@@ -882,11 +790,10 @@ export class OnboardingService {
activeNode,
activeNodeDraftState,
currentQuestion,
readToken,
}),
currentQuestion,
draft,
finishedAt: state.finishedAt,
currentQuestion,
topicId: state.activeTopicId,
version: state.version,
};
@@ -895,24 +802,7 @@ export class OnboardingService {
askQuestion = async (params: {
node: UserAgentOnboardingNode;
question: UserAgentOnboardingQuestionDraft;
readToken: string;
}): Promise<AskQuestionResult> => {
const readTokenResult = await this.consumeReadToken(params.readToken);
if (!readTokenResult.success) {
const nextContext = await this.getState();
const error = readTokenResult.error!;
return {
activeNode: nextContext.activeNode,
content: error.message,
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
instruction: readTokenResult.instruction,
success: false,
};
}
const context = await this.getState();
const activeNode = context.activeNode;
@@ -925,14 +815,11 @@ export class OnboardingService {
}
if (params.node !== activeNode) {
const instruction = getNodeRecoveryInstruction(activeNode);
return {
activeNode,
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}". ${instruction}`,
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}".`,
control: context.control,
currentQuestion: context.currentQuestion,
instruction,
mismatch: true,
requestedNode: params.node,
success: false,
@@ -964,29 +851,8 @@ export class OnboardingService {
};
saveAnswer = async (params: {
readToken: string;
updates: Array<Pick<UserAgentOnboardingUpdate, 'node'> & { patch: OnboardingPatchInput }>;
}): Promise<ProposePatchResult> => {
const readTokenResult = await this.consumeReadToken(params.readToken);
if (!readTokenResult.success) {
const nextContext = await this.getState();
const error = readTokenResult.error!;
return {
activeNode: nextContext.activeNode,
activeNodeDraftState: nextContext.activeNodeDraftState,
content: error.message,
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
draft: nextContext.draft,
error,
instruction: readTokenResult.instruction,
nextAction: 'ask',
success: false,
};
}
const updates = [...params.updates].sort((left, right) => {
return getNodeIndex(left.node) - getNodeIndex(right.node);
});
@@ -1001,41 +867,37 @@ export class OnboardingService {
latestResult = result;
contentParts.push(result.content);
if (result.success) {
processedNodes.push(update.node);
if (!result.success) {
const nextContext = await this.getState();
if (result.activeNodeDraftState?.status === 'partial') {
const nextContext = await this.getState();
return {
...result,
content: contentParts.join('\n'),
activeNode: nextContext.activeNode,
activeNodeDraftState: nextContext.activeNodeDraftState,
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
draft: nextContext.draft,
processedNodes,
};
}
continue;
return {
...result,
activeNode: nextContext.activeNode,
activeNodeDraftState: nextContext.activeNodeDraftState,
content: contentParts.join('\n'),
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
draft: nextContext.draft,
processedNodes,
};
}
if (result.mismatch) continue;
processedNodes.push(update.node);
const nextContext = await this.getState();
if (result.activeNodeDraftState?.status === 'partial') {
const nextContext = await this.getState();
return {
...result,
content: contentParts.join('\n'),
activeNode: nextContext.activeNode,
activeNodeDraftState: nextContext.activeNodeDraftState,
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
draft: nextContext.draft,
processedNodes,
};
return {
...result,
activeNode: nextContext.activeNode,
activeNodeDraftState: nextContext.activeNodeDraftState,
content: contentParts.join('\n'),
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
draft: nextContext.draft,
processedNodes,
};
}
}
const nextContext = await this.getState();
@@ -1047,9 +909,9 @@ export class OnboardingService {
nextAction: 'ask' as const,
success: false,
}),
content: contentParts.join('\n'),
activeNode: nextContext.activeNode,
activeNodeDraftState: nextContext.activeNodeDraftState,
content: contentParts.join('\n'),
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
draft: nextContext.draft,
@@ -1077,51 +939,14 @@ export class OnboardingService {
}
if (params.node !== activeNode) {
const activeNodeIndex = getNodeIndex(activeNode);
const requestedNodeIndex = getNodeIndex(params.node);
const recoverableDraft =
requestedNodeIndex > activeNodeIndex
? extractDraftForNode(params.node, params.patch)
: undefined;
if (recoverableDraft) {
const draft = mergeDraftForNode(
context.draft,
params.node,
getDraftValueForNode(recoverableDraft, params.node),
);
const instruction = getNodeRecoveryInstruction(activeNode);
await this.saveState({ ...(await this.ensurePersistedState()), draft });
return {
activeNode,
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}". I saved the later-step draft for "${params.node}", but do not advance yet. ${instruction}`,
draft,
error: {
code: 'NODE_MISMATCH',
message: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}".`,
},
instruction,
mismatch: true,
nextAction: 'ask',
requestedNode: params.node,
savedDraftFields: Object.keys(recoverableDraft) as (keyof UserAgentOnboardingDraft)[],
success: false,
};
}
const instruction = getNodeRecoveryInstruction(activeNode);
return {
activeNode,
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}". ${instruction}`,
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}".`,
draft: context.draft,
error: {
code: 'NODE_MISMATCH',
message: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}".`,
},
instruction,
mismatch: true,
nextAction: 'ask',
requestedNode: params.node,
@@ -1182,10 +1007,9 @@ export class OnboardingService {
committedValue: getDraftValueForNode(draft, activeNode),
content: commitResult.content,
control: commitResult.control,
currentQuestion: commitResult.currentQuestion,
draft: {},
instruction: commitResult.instruction,
nextAction: 'ask',
requiresReadBeforeNextTool: commitResult.requiresReadBeforeNextTool,
success: commitResult.success,
};
}
@@ -1213,7 +1037,7 @@ export class OnboardingService {
private commitActiveStep = async (
state: UserAgentOnboarding,
activeNode: UserAgentOnboardingNode,
) => {
): Promise<CommitStepResult> => {
const draft = state.draft ?? {};
switch (activeNode) {
@@ -1231,7 +1055,6 @@ export class OnboardingService {
? state.questionSurface.question
: undefined,
}),
nextNode: activeNode,
success: false,
};
}
@@ -1253,7 +1076,6 @@ export class OnboardingService {
? state.questionSurface.question
: undefined,
}),
nextNode: activeNode,
success: false,
};
}
@@ -1283,7 +1105,6 @@ export class OnboardingService {
? state.questionSurface.question
: undefined,
}),
nextNode: activeNode,
success: false,
};
}
@@ -1308,7 +1129,6 @@ export class OnboardingService {
? state.questionSurface.question
: undefined,
}),
nextNode: activeNode,
success: false,
};
}
@@ -1344,7 +1164,6 @@ export class OnboardingService {
? state.questionSurface.question
: undefined,
}),
nextNode: activeNode,
success: false,
};
}
@@ -1367,7 +1186,6 @@ export class OnboardingService {
? state.questionSurface.question
: undefined,
}),
nextNode: activeNode,
success: false,
};
}
@@ -1383,17 +1201,30 @@ export class OnboardingService {
case 'proSettings': {
const defaultModel = normalizeDefaultModel(draft.defaultModel);
if (defaultModel) {
const currentSettings = await this.userModel.getUserSettings();
await this.userModel.updateSetting({
defaultAgent: merge(currentSettings?.defaultAgent || {}, {
config: {
model: defaultModel.model,
provider: defaultModel.provider,
},
if (!defaultModel) {
return {
content: 'Default model has not been captured yet.',
control: buildOnboardingControl({
activeNode,
activeNodeDraftState: getNodeDraftState(activeNode, draft),
currentQuestion:
state.questionSurface?.node === activeNode
? state.questionSurface.question
: undefined,
}),
});
success: false,
};
}
const currentSettings = await this.userModel.getUserSettings();
await this.userModel.updateSetting({
defaultAgent: merge(currentSettings?.defaultAgent || {}, {
config: {
model: defaultModel.model,
provider: defaultModel.provider,
},
}),
});
break;
}
case 'summary': {
@@ -1401,13 +1232,11 @@ export class OnboardingService {
content: 'Use finishOnboarding from the summary step.',
control: buildOnboardingControl({
activeNode,
activeNodeDraftState: undefined,
currentQuestion:
state.questionSurface?.node === activeNode
? state.questionSurface.question
: undefined,
}),
nextNode: activeNode,
success: false,
};
}
@@ -1437,38 +1266,14 @@ export class OnboardingService {
content: nextNode
? `Committed step "${activeNode}". Continue with "${nextNode}".`
: `Committed step "${activeNode}".`,
activeNodeDraftState: nextContext.activeNodeDraftState,
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
instruction:
nextNode && !nextContext.currentQuestion
? `State advanced to "${nextNode}". Call getOnboardingState again before any other onboarding tool. If the next step still has no current question, call askUserQuestion for "${nextNode}" before replying.`
: undefined,
nextNode: nextNode ?? activeNode,
requiresReadBeforeNextTool: true,
success: true,
};
};
completeCurrentStep = async (node: UserAgentOnboardingNode, readToken: string) => {
const readTokenResult = await this.consumeReadToken(readToken);
if (!readTokenResult.success) {
const nextContext = await this.getState();
const error = readTokenResult.error!;
return {
activeNode: nextContext.activeNode,
activeNodeDraftState: nextContext.activeNodeDraftState,
content: error.message,
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
instruction: readTokenResult.instruction,
success: false,
};
}
const state = readTokenResult.state;
completeCurrentStep = async (node: UserAgentOnboardingNode) => {
const state = await this.ensurePersistedState();
const activeNode = getActiveNode(state);
if (!activeNode) {
@@ -1485,16 +1290,32 @@ export class OnboardingService {
if (node !== activeNode) {
return {
activeNode,
activeNodeDraftState: getNodeDraftState(activeNode, state.draft ?? {}),
content: `Active onboarding step is "${activeNode}", not "${node}".`,
control: buildOnboardingControl({
activeNode,
activeNodeDraftState: getNodeDraftState(activeNode, state.draft ?? {}),
currentQuestion:
state.questionSurface && state.questionSurface.node === activeNode
? state.questionSurface.question
: undefined,
state.questionSurface?.node === activeNode ? state.questionSurface.question : undefined,
}),
success: false,
};
}
const draftState = getNodeDraftState(activeNode, state.draft ?? {});
if (activeNode !== 'summary' && draftState?.status !== 'complete') {
return {
activeNode,
activeNodeDraftState: draftState,
content: `Active onboarding step "${activeNode}" is still incomplete.`,
control: buildOnboardingControl({
activeNode,
activeNodeDraftState: draftState,
currentQuestion:
state.questionSurface?.node === activeNode ? state.questionSurface.question : undefined,
}),
nextNode: activeNode,
success: false,
};
}
@@ -1502,36 +1323,19 @@ export class OnboardingService {
return this.commitActiveStep(state, activeNode);
};
returnToOnboarding = async (readToken: string, reason?: string) => {
const readTokenResult = await this.consumeReadToken(readToken);
if (!readTokenResult.success) {
const nextContext = await this.getState();
const error = readTokenResult.error!;
return {
activeNode: nextContext.activeNode,
content: error.message,
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
instruction: readTokenResult.instruction,
success: false,
};
}
const state = readTokenResult.state;
returnToOnboarding = async (reason?: string) => {
const state = await this.ensurePersistedState();
const activeNode = getActiveNode(state);
const draft = state.draft ?? {};
const questionSurface = state.questionSurface;
const currentQuestion =
state.questionSurface && state.questionSurface.node === activeNode
? state.questionSurface.question
: undefined;
questionSurface && questionSurface.node === activeNode ? questionSurface.question : undefined;
return {
activeNode,
content: reason
? `Stay on onboarding. Off-topic reason: ${reason}`
: 'Stay on onboarding and continue with the current question.',
activeNode,
control: buildOnboardingControl({
activeNode,
activeNodeDraftState: getNodeDraftState(activeNode, draft),
@@ -1542,35 +1346,21 @@ export class OnboardingService {
};
};
finishOnboarding = async (readToken: string) => {
const readTokenResult = await this.consumeReadToken(readToken);
if (!readTokenResult.success) {
const nextContext = await this.getState();
const error = readTokenResult.error!;
return {
activeNode: nextContext.activeNode,
content: error.message,
control: nextContext.control,
currentQuestion: nextContext.currentQuestion,
instruction: readTokenResult.instruction,
success: false,
};
}
const state = readTokenResult.state;
finishOnboarding = async () => {
const state = await this.ensurePersistedState();
const activeNode = getActiveNode(state);
if (activeNode !== 'summary') {
const questionSurface = state.questionSurface;
return {
content: `Active onboarding step is "${activeNode ?? 'completed'}". Finish is only allowed in "summary".`,
control: buildOnboardingControl({
activeNode,
activeNodeDraftState: getNodeDraftState(activeNode, state.draft ?? {}),
currentQuestion:
state.questionSurface && state.questionSurface.node === activeNode
? state.questionSurface.question
questionSurface && questionSurface.node === activeNode
? questionSurface.question
: undefined,
}),
success: false,

View File

@@ -21,7 +21,7 @@ export const webOnboardingRuntime: ServerRuntimeRegistration = {
proxy[api.name] = async (args: Record<string, unknown>) => {
switch (api.name) {
case 'getOnboardingState': {
const result = await service.getState({ issueReadToken: true });
const result = await service.getState();
return { content: JSON.stringify(result, null, 2), state: result, success: true };
}
@@ -36,23 +36,17 @@ export const webOnboardingRuntime: ServerRuntimeRegistration = {
return createWebOnboardingToolResult(result);
}
case 'completeCurrentStep': {
const result = await service.completeCurrentStep(
args.node as any,
args.readToken as string,
);
const result = await service.completeCurrentStep(args.node as any);
return createWebOnboardingToolResult(result);
}
case 'returnToOnboarding': {
const result = await service.returnToOnboarding(
args.readToken as string,
args.reason as string | undefined,
);
const result = await service.returnToOnboarding(args.reason as string | undefined);
return createWebOnboardingToolResult(result);
}
case 'finishOnboarding': {
const result = await service.finishOnboarding(args.readToken as string);
const result = await service.finishOnboarding();
return createWebOnboardingToolResult(result);
}

View File

@@ -45,10 +45,7 @@ export class UserService {
return lambdaClient.user.getOnboardingState.query();
};
saveOnboardingAnswer = async (params: {
readToken: string;
updates: UserAgentOnboardingUpdate[];
}) => {
saveOnboardingAnswer = async (params: { updates: UserAgentOnboardingUpdate[] }) => {
return lambdaClient.user.saveOnboardingAnswer.mutate(
params as Parameters<typeof lambdaClient.user.saveOnboardingAnswer.mutate>[0],
);
@@ -57,21 +54,20 @@ export class UserService {
askOnboardingQuestion = async (params: {
node: UserAgentOnboardingNode;
question: UserAgentOnboardingQuestionDraft;
readToken: string;
}) => {
return lambdaClient.user.askOnboardingQuestion.mutate(params);
};
completeOnboardingStep = async (node: UserAgentOnboardingNode, readToken: string) => {
return lambdaClient.user.completeOnboardingStep.mutate({ node, readToken });
completeOnboardingStep = async (node: UserAgentOnboardingNode) => {
return lambdaClient.user.completeOnboardingStep.mutate({ node });
};
returnToOnboarding = async (readToken: string, reason?: string) => {
return lambdaClient.user.returnToOnboarding.mutate({ readToken, reason });
returnToOnboarding = async (reason?: string) => {
return lambdaClient.user.returnToOnboarding.mutate({ reason });
};
finishOnboarding = async (readToken: string) => {
return lambdaClient.user.finishOnboarding.mutate({ readToken });
finishOnboarding = async () => {
return lambdaClient.user.finishOnboarding.mutate({});
};
makeUserOnboarded = async () => {

View File

@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { userService } from '@/services/user';
import { useUserStore } from '@/store/user';
import { webOnboardingExecutor } from './lobe-web-onboarding';
vi.mock('@/services/user', () => ({
userService: {
askOnboardingQuestion: vi.fn(),
completeOnboardingStep: vi.fn(),
finishOnboarding: vi.fn(),
getOnboardingState: vi.fn(),
returnToOnboarding: vi.fn(),
saveOnboardingAnswer: vi.fn(),
},
}));
vi.mock('@/store/user', () => ({
useUserStore: {
getState: vi.fn(),
},
}));
describe('webOnboardingExecutor', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useUserStore.getState).mockReturnValue({
refreshUserState: vi.fn().mockResolvedValue(undefined),
} as any);
});
it('refreshes user onboarding state after askUserQuestion', async () => {
const refreshUserState = vi.fn().mockResolvedValue(undefined);
vi.mocked(useUserStore.getState).mockReturnValue({
refreshUserState,
} as any);
vi.mocked(userService.askOnboardingQuestion).mockResolvedValue({
content: 'Saved the current question for "agentIdentity".',
currentQuestion: {
id: 'agent_identity_001',
mode: 'button_group',
node: 'agentIdentity',
prompt: '贾维斯的气质应该是什么样的?',
},
success: true,
});
await webOnboardingExecutor.askUserQuestion(
{
node: 'agentIdentity',
question: {
id: 'agent_identity_001',
mode: 'button_group',
prompt: '贾维斯的气质应该是什么样的?',
},
},
{} as any,
);
expect(refreshUserState).toHaveBeenCalled();
});
});

View File

@@ -6,9 +6,18 @@ import { type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types
import { BaseExecutor } from '@lobechat/types';
import { userService } from '@/services/user';
import { useUserStore } from '@/store/user';
import { createWebOnboardingToolResult } from '../../../../../utils/webOnboardingToolResult';
const syncUserOnboardingState = async () => {
try {
await useUserStore.getState().refreshUserState();
} catch (error) {
console.error(error);
}
};
class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
readonly identifier = WebOnboardingIdentifier;
protected readonly apiEnum = WebOnboardingApiName;
@@ -25,12 +34,12 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
saveAnswer = async (
params: {
readToken: string;
updates: Parameters<typeof userService.saveOnboardingAnswer>[0]['updates'];
},
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.saveOnboardingAnswer(params);
await syncUserOnboardingState();
return createWebOnboardingToolResult(result);
};
@@ -39,11 +48,11 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
params: {
node: Parameters<typeof userService.askOnboardingQuestion>[0]['node'];
question: Parameters<typeof userService.askOnboardingQuestion>[0]['question'];
readToken: Parameters<typeof userService.askOnboardingQuestion>[0]['readToken'];
},
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.askOnboardingQuestion(params);
await syncUserOnboardingState();
return {
content: result.content,
@@ -55,29 +64,28 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
completeCurrentStep = async (
params: {
node: Parameters<typeof userService.completeOnboardingStep>[0];
readToken: Parameters<typeof userService.completeOnboardingStep>[1];
},
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.completeOnboardingStep(params.node, params.readToken);
const result = await userService.completeOnboardingStep(params.node);
await syncUserOnboardingState();
return createWebOnboardingToolResult(result);
};
returnToOnboarding = async (
params: { readToken: string; reason?: string },
params: { reason?: string },
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.returnToOnboarding(params.readToken, params.reason);
const result = await userService.returnToOnboarding(params.reason);
await syncUserOnboardingState();
return createWebOnboardingToolResult(result);
};
finishOnboarding = async (
params: { readToken: string },
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.finishOnboarding(params.readToken);
finishOnboarding = async (_params: Record<string, never>, _ctx: BuiltinToolContext) => {
const result = await userService.finishOnboarding();
await syncUserOnboardingState();
return createWebOnboardingToolResult(result);
};