feat(onboarding): overhaul onboarding flow with new question structure and refined interaction rules

- Replaced existing interaction hints with a focused question structure to enhance user engagement.
- Updated system role instructions to clarify onboarding protocols and improve conversational flow.
- Refactored type definitions and manifest to align with the new onboarding schema.
- Removed deprecated interaction hint components and tests to streamline the codebase.

This update aims to create a more structured and engaging onboarding experience for users, ensuring clarity and efficiency in interactions.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-03-24 15:52:21 +08:00
parent ff8a2909cc
commit 2db19de1dc
25 changed files with 1778 additions and 2137 deletions

View File

@@ -229,11 +229,12 @@
"builtins.lobe-web-browsing.apiName.search": "搜索页面",
"builtins.lobe-web-browsing.inspector.noResults": "无结果",
"builtins.lobe-web-browsing.title": "联网搜索",
"builtins.lobe-web-onboarding.apiName.commitOnboardingNode": "确认引导步骤",
"builtins.lobe-web-onboarding.apiName.finishAgentOnboarding": "完成引导",
"builtins.lobe-web-onboarding.apiName.getOnboardingContext": "读取引导上下文",
"builtins.lobe-web-onboarding.apiName.proposeOnboardingPatch": "更新引导状态",
"builtins.lobe-web-onboarding.apiName.redirectOfftopic": "返回用户引导",
"builtins.lobe-web-onboarding.apiName.askUserQuestion": "向用户提问",
"builtins.lobe-web-onboarding.apiName.completeCurrentStep": "完成当前步骤",
"builtins.lobe-web-onboarding.apiName.finishOnboarding": "完成引导",
"builtins.lobe-web-onboarding.apiName.getOnboardingState": "读取引导状态",
"builtins.lobe-web-onboarding.apiName.returnToOnboarding": "回到引导流程",
"builtins.lobe-web-onboarding.apiName.saveAnswer": "保存用户答案",
"builtins.lobe-web-onboarding.title": "用户引导",
"confirm": "确认",
"debug.arguments": "调用参数",

View File

@@ -1,101 +1,120 @@
const systemRoleTemplate = `
You are the dedicated web onboarding agent.
Your job is to make onboarding feel like a real first conversation, not a form. Be warm, present, observant, and a little opinionated. Sound like a person with taste, not a scripted assistant. But stay disciplined: your only goal is to complete onboarding, one node at a time.
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
Style:
- Keep replies concise: usually 1 short paragraph, sometimes 2.
- Match the user's language.
- Sound like a real person, not support copy.
- Be concise. Usually 1 short paragraph. Sometimes 2.
- Ask one focused question at a time.
- Be natural, relaxed, and specific.
- Cut filler like "Great question", "Happy to help", or generic praise.
- You can have personality, but never let personality distract from progress.
- Cut filler, praise, and generic enthusiasm.
- Be warm and direct, but do not lock into a fixed persona before agentIdentity is committed.
Language rule:
- The preferred reply language is mandatory, not a suggestion.
Language:
- The preferred reply language is mandatory.
- Every visible reply must be entirely in that language unless the user explicitly switches languages.
- Do not drift into English for convenience. Keep tool names and schema keys in English only inside tool calls, not in the user-facing reply.
- Before sending a reply, do a final check that every sentence follows the required language.
- 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.
Tool and flow rules:
1. Always call getOnboardingContext at the start of every turn before deciding what to do next.
2. Treat getOnboardingContext as the source of truth for activeNode, committed values, completed nodes, draft values, interactionHints, and interactionPolicy.
3. Only use onboarding tools to advance the flow: getOnboardingContext, proposeOnboardingInteractions, proposeOnboardingPatch, commitOnboardingNode, redirectOfftopic, and finishAgentOnboarding.
4. If interactionPolicy.needsRefresh is true, your next tool call after getOnboardingContext must be proposeOnboardingInteractions. Do not send a user-facing reply first.
5. Every button_group action must be executable. Prefer payload.message for conversational choices and payload.patch for direct structured submissions. Do not generate inert buttons.
Non-negotiable protocol:
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
- 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 invent onboarding fields, nodes, or values outside the supported schema.
8. Never claim a value was saved, committed, or finished unless the relevant tool call succeeded.
9. Do not expose internal tool names or node names unless it is genuinely necessary.
7. Never expose internal tool names or node names unless it is genuinely necessary.
Conversation goals:
- First, establish your own identity with the user: name, nature, vibe, emoji.
- Then build a dimensional picture of the user across:
- Identity
- Nature
- Vibe
- Interests and tools
- Current focus
- Pain points
- By the summary step, turn that picture into 3-5 concrete service suggestions.
Turn algorithm:
1. Call getOnboardingState.
2. Read activeNode, activeNodeDraftState, currentQuestion, draft, and committed values.
3. Choose exactly one primary action for the active node:
- askUserQuestion:
Use this when structured interaction would help the user answer with less effort, or when you want to replace the current question with a better one.
- saveAnswer:
Use this when the user has already given one or more usable fields for the active node.
Batch multiple consecutive nodes only when the user clearly answered them in one turn.
- completeCurrentStep:
Use this only when a reliable draft already exists and the user is just confirming it.
- 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.
How to operate on each turn:
1. Read the current onboarding context first.
2. Focus only on the active step.
3. For the active step, do exactly one of these:
- ask a focused question to get the missing value
- generate or refresh the interactionHints with proposeOnboardingInteractions when a richer UI would help
- confirm a draft value only if the tool flow genuinely requires confirmation
- submit a proposeOnboardingPatch when the user's answer is clear
- use commitOnboardingNode only when there is already a reliable draft value that just needs confirmation
4. After a successful proposeOnboardingPatch call, do not ask for an extra confirmation unless the tool explicitly says confirmation is still needed.
5. If a tool says information is missing or unclear, ask the smallest possible follow-up question.
6. Never skip ahead with a later-node tool call just because the user mentioned later-step information early. Finish the active step first.
7. proposeOnboardingPatch accepts batch updates. When the user clearly provides information for multiple consecutive nodes in one turn, batch those updates in order.
8. If getOnboardingContext returns only weak fallback interactionHints, proactively call proposeOnboardingInteractions to generate better interaction surfaces for the current node.
9. For a newly active node, default to generating at least one useful interaction surface unless the current interactionHints already contain a strong form or actionable button group.
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 when free text is genuinely the best interface
- 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.
- In saveAnswer, patch is node-scoped. Because node is already provided, send only that node's fields. Example: for agentIdentity, send { vibe: "playful" } instead of { agentIdentity: { vibe: "playful" } }.
- Do not rely on placeholder questions from the server. If you want interactive options, define them yourself with askUserQuestion.
Interaction policy:
- Weak fallback interactionHints means generic composer_prefill or info-only hints, or button groups without executable payloads.
- interactionPolicy.needsRefresh = true means you should generate interaction hints now, before replying.
- For agentIdentity, userIdentity, workStyle, workContext, and painPoints, prefer concrete button groups or forms over pure text whenever that would save the user typing.
- When you generate button groups, make the button labels natural user answers, and include payload.message unless payload.patch is the better fit.
Active-step guidance:
Question strategy by node:
- agentIdentity:
- You are a blank slate who just came online.
- Help the user decide your name, nature, vibe, and emoji.
- If they only say "hi" or give no direction, proactively offer 2-4 concrete options instead of asking an empty open question.
- If they keep saying "whatever", choose a coherent identity yourself and ask for a light confirmation.
- Even if the user also introduces themselves in the same turn, do not call userIdentity yet. Finish agentIdentity first.
- userIdentity: capture who they are, what they do, and where they sit professionally. Do not settle for one vague title.
- workStyle: capture how they think, decide, communicate, and work with others. This is where you cover both Nature and Vibe.
- workContext: capture what they care about, what they use, and what they are focused on right now. This is where you cover Interests and Tools plus Current Focus.
- painPoints: capture the actual friction. Look for bottlenecks, neglected work, recurring frustrations, and unmet needs.
- responseLanguage: capture the default reply language clearly.
- proSettings: if the user gives a model/provider preference, capture it; if they are simply ready to continue, move on without inventing extra setup work.
- You just came online. You do not know your 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.
- userIdentity:
- Figure out who they are professionally.
- Capture enough detail to avoid vague labels.
- workStyle:
- Figure out how they think, decide, communicate, and work with others.
- workContext:
- Figure out what they are focused on now, what tools they use, and what domains they care about.
- painPoints:
- Figure out where friction actually lives.
- Look for bottlenecks, neglected work, repeated frustration, and unmet needs.
- 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 your impression of them like you would tell a friend about someone interesting you just met.
- Do not output a sterile form or audit report.
- Then list 3-5 specific, actionable ways you can help based on their focus and pain points.
- 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 whether that lands and where they want to start.
Boundary handling:
- If the user goes off-topic, briefly acknowledge it, call redirectOfftopic, and bring them back to the current onboarding question.
- Do not turn onboarding into open-ended interviewing, therapy, career coaching, or profile building.
- Do not ask broad discovery questions unless they directly help complete the current onboarding node.
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.
Completion rule:
- Only when activeNode is "summary" should you summarize the setup and ask whether it looks right.
- After the user clearly confirms the summary, call finishAgentOnboarding.
- Never call finishAgentOnboarding before the summary step.
Completion:
- Only the summary node can end onboarding.
- Do not call finishOnboarding before the summary is shown and confirmed.
`.trim();
export const createSystemRole = (userLocale?: string) =>
[
systemRoleTemplate,
userLocale
? `Preferred reply language: ${userLocale}. This is mandatory. Every visible reply must be entirely in ${userLocale} unless the user explicitly asks to switch. If you drafted any sentence in 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. If any sentence drifts into another language, rewrite it before sending.`
: '',
]
.filter(Boolean)

View File

@@ -7,8 +7,8 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
api: [
{
description:
'Read the current web onboarding state, including the active step, committed values, saved draft, and interaction hints for the next UI surface.',
name: WebOnboardingApiName.getOnboardingContext,
'Read the current onboarding state, including the active step, committed values, saved draft, and any currently stored question surface.',
name: WebOnboardingApiName.getOnboardingState,
parameters: {
properties: {},
type: 'object',
@@ -17,195 +17,10 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
},
{
description:
'Propose interaction hints for the current onboarding step. Use this to generate button groups, forms, selects, info cards, or composer prefills that help the user respond faster.',
name: WebOnboardingApiName.proposeOnboardingInteractions,
'Define the current question for the active onboarding step. Use this to ask one focused question and attach the best answer surface, such as choices, a form, a select, or a composer prefill.',
name: WebOnboardingApiName.askUserQuestion,
parameters: {
properties: {
hints: {
items: {
properties: {
actions: {
items: {
properties: {
id: { type: 'string' },
label: { type: 'string' },
payload: {
properties: {
kind: {
enum: ['message', 'patch'],
type: 'string',
},
message: { type: 'string' },
patch: {
properties: {
agentIdentity: {
properties: {
emoji: { type: 'string' },
name: { type: 'string' },
nature: { type: 'string' },
vibe: { type: 'string' },
},
type: 'object',
},
defaultModel: {
properties: {
model: { type: 'string' },
provider: { type: 'string' },
},
type: 'object',
},
painPoints: {
properties: {
blockedBy: {
items: { type: 'string' },
type: 'array',
},
frustrations: {
items: { type: 'string' },
type: 'array',
},
noTimeFor: {
items: { type: 'string' },
type: 'array',
},
summary: { type: 'string' },
},
type: 'object',
},
responseLanguage: { type: 'string' },
userIdentity: {
properties: {
domainExpertise: { type: 'string' },
name: { type: 'string' },
professionalRole: { type: 'string' },
summary: { type: 'string' },
},
type: 'object',
},
workContext: {
properties: {
activeProjects: {
items: { type: 'string' },
type: 'array',
},
currentFocus: { type: 'string' },
interests: {
items: { type: 'string' },
type: 'array',
},
summary: { type: 'string' },
thisQuarter: { type: 'string' },
thisWeek: { type: 'string' },
tools: {
items: { type: 'string' },
type: 'array',
},
},
type: 'object',
},
workStyle: {
properties: {
communicationStyle: { type: 'string' },
decisionMaking: { type: 'string' },
socialMode: { type: 'string' },
summary: { type: 'string' },
thinkingPreferences: { type: 'string' },
workStyle: { type: 'string' },
},
type: 'object',
},
},
type: 'object',
},
targetNode: {
enum: [
'agentIdentity',
'userIdentity',
'workStyle',
'workContext',
'painPoints',
'responseLanguage',
'proSettings',
'summary',
],
type: 'string',
},
},
required: ['kind'],
type: 'object',
},
style: {
enum: ['danger', 'default', 'primary'],
type: 'string',
},
},
required: ['id', 'label'],
type: 'object',
},
type: 'array',
},
description: { type: 'string' },
fields: {
items: {
properties: {
key: { type: 'string' },
kind: {
enum: ['emoji', 'multiselect', 'select', 'text', 'textarea'],
type: 'string',
},
label: { type: 'string' },
options: {
items: {
properties: {
label: { type: 'string' },
value: { type: 'string' },
},
required: ['label', 'value'],
type: 'object',
},
type: 'array',
},
placeholder: { type: 'string' },
required: { type: 'boolean' },
value: {
oneOf: [
{ type: 'string' },
{
items: { type: 'string' },
type: 'array',
},
],
},
},
required: ['key', 'kind', 'label'],
type: 'object',
},
type: 'array',
},
id: { type: 'string' },
kind: {
enum: ['button_group', 'composer_prefill', 'form', 'info', 'select'],
type: 'string',
},
metadata: {
additionalProperties: true,
type: 'object',
},
priority: {
enum: ['primary', 'secondary'],
type: 'string',
},
submitMode: {
enum: ['message', 'tool'],
type: 'string',
},
title: { type: 'string' },
},
required: ['id', 'kind'],
type: 'object',
},
type: 'array',
},
node: {
enum: [
'agentIdentity',
@@ -219,15 +34,120 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
],
type: 'string',
},
question: {
properties: {
choices: {
items: {
properties: {
id: { type: 'string' },
label: { type: 'string' },
payload: {
properties: {
kind: {
enum: ['message', 'patch'],
type: 'string',
},
message: { type: 'string' },
patch: {
additionalProperties: true,
type: 'object',
},
targetNode: {
enum: [
'agentIdentity',
'userIdentity',
'workStyle',
'workContext',
'painPoints',
'responseLanguage',
'proSettings',
'summary',
],
type: 'string',
},
},
required: ['kind'],
type: 'object',
},
style: {
enum: ['danger', 'default', 'primary'],
type: 'string',
},
},
required: ['id', 'label'],
type: 'object',
},
type: 'array',
},
description: { type: 'string' },
fields: {
items: {
properties: {
key: { type: 'string' },
kind: {
enum: ['emoji', 'multiselect', 'select', 'text', 'textarea'],
type: 'string',
},
label: { type: 'string' },
options: {
items: {
properties: {
label: { type: 'string' },
value: { type: 'string' },
},
required: ['label', 'value'],
type: 'object',
},
type: 'array',
},
placeholder: { type: 'string' },
required: { type: 'boolean' },
value: {
oneOf: [
{ type: 'string' },
{
items: { type: 'string' },
type: 'array',
},
],
},
},
required: ['key', 'kind', 'label'],
type: 'object',
},
type: 'array',
},
id: { type: 'string' },
metadata: {
additionalProperties: true,
type: 'object',
},
mode: {
enum: ['button_group', 'composer_prefill', 'form', 'info', 'select'],
type: 'string',
},
priority: {
enum: ['primary', 'secondary'],
type: 'string',
},
prompt: { type: 'string' },
submitMode: {
enum: ['message', 'tool'],
type: 'string',
},
},
required: ['id', 'mode', 'prompt'],
type: 'object',
},
},
required: ['node', 'hints'],
required: ['node', 'question'],
type: 'object',
},
},
{
description:
'Propose one or more structured onboarding updates. Use batch updates when the user provided information for multiple nodes in the same turn.',
name: WebOnboardingApiName.proposeOnboardingPatch,
'Save one or more structured answers from the user. patch is node-scoped and may be partial: because node is already provided, send only that nodes fields. Use batch updates when the user clearly answered multiple consecutive onboarding steps in one turn.',
name: WebOnboardingApiName.saveAnswer,
parameters: {
properties: {
updates: {
@@ -247,84 +167,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
type: 'string',
},
patch: {
properties: {
agentIdentity: {
properties: {
emoji: { type: 'string' },
name: { type: 'string' },
nature: { type: 'string' },
vibe: { type: 'string' },
},
type: 'object',
},
defaultModel: {
properties: {
model: { type: 'string' },
provider: { type: 'string' },
},
type: 'object',
},
painPoints: {
properties: {
blockedBy: {
items: { type: 'string' },
type: 'array',
},
frustrations: {
items: { type: 'string' },
type: 'array',
},
noTimeFor: {
items: { type: 'string' },
type: 'array',
},
summary: { type: 'string' },
},
type: 'object',
},
responseLanguage: { type: 'string' },
userIdentity: {
properties: {
domainExpertise: { type: 'string' },
name: { type: 'string' },
professionalRole: { type: 'string' },
summary: { type: 'string' },
},
type: 'object',
},
workContext: {
properties: {
activeProjects: {
items: { type: 'string' },
type: 'array',
},
currentFocus: { type: 'string' },
interests: {
items: { type: 'string' },
type: 'array',
},
summary: { type: 'string' },
thisQuarter: { type: 'string' },
thisWeek: { type: 'string' },
tools: {
items: { type: 'string' },
type: 'array',
},
},
type: 'object',
},
workStyle: {
properties: {
communicationStyle: { type: 'string' },
decisionMaking: { type: 'string' },
socialMode: { type: 'string' },
summary: { type: 'string' },
thinkingPreferences: { type: 'string' },
workStyle: { type: 'string' },
},
type: 'object',
},
},
additionalProperties: true,
type: 'object',
},
},
@@ -340,8 +183,8 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
},
{
description:
'Commit the active onboarding step after the user has provided a reliable or confirmed answer.',
name: WebOnboardingApiName.commitOnboardingNode,
'Complete the active onboarding step after the user has provided a reliable or confirmed answer.',
name: WebOnboardingApiName.completeCurrentStep,
parameters: {
properties: {
node: {
@@ -364,8 +207,8 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
},
{
description:
'Record an off-topic turn and return the flow back to the active onboarding question.',
name: WebOnboardingApiName.redirectOfftopic,
'Record an off-topic turn and bring the conversation back to the active onboarding question.',
name: WebOnboardingApiName.returnToOnboarding,
parameters: {
properties: {
reason: { type: 'string' },
@@ -375,8 +218,8 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
},
{
description:
'Finish the agent onboarding flow from the summary node and mirror the legacy onboarding completion flag.',
name: WebOnboardingApiName.finishAgentOnboarding,
'Finish the onboarding flow from the summary step and mirror the legacy onboarding completion flag.',
name: WebOnboardingApiName.finishOnboarding,
parameters: {
properties: {},
type: 'object',

View File

@@ -1,32 +1,58 @@
export const systemPrompt = `
You are the only toolset allowed to manage the web onboarding flow.
Rules:
1. Always call getOnboardingContext before making onboarding decisions.
2. Treat getOnboardingContext as the source of truth for activeNode, committed values, completedNodes, draft, interactionHints, and interactionPolicy.
3. Only advance the flow through proposeOnboardingPatch, proposeOnboardingInteractions, commitOnboardingNode, and finishAgentOnboarding.
4. Prefer proposeOnboardingPatch when the user has provided a clear answer for the active step.
5. If interactionPolicy.needsRefresh is true, your next tool call after getOnboardingContext must be proposeOnboardingInteractions. Do not send a user-facing reply first.
6. When you generate button_group hints, make every action executable. Prefer payload.message for conversational choices and payload.patch for direct structured submissions. Do not generate inert buttons.
7. Use commitOnboardingNode only when a reliable draft already exists and the user is confirming it.
8. If the user goes off-topic, use redirectOfftopic and guide them back.
9. Do not answer unrelated requests in depth during onboarding.
10. Ask one onboarding question at a time.
11. Do not claim that onboarding data was saved unless the tool call succeeded.
12. Never finish onboarding before the summary step.
13. Never call a later node before the active step is complete, even if the user mentions later-step information early.
14. proposeOnboardingPatch accepts batch updates. When the user clearly provides information for multiple consecutive nodes in one turn, send them together in order.
15. proposeOnboardingInteractions only applies to the current node. Use it to replace or enrich the current interaction surface; do not use it for later nodes.
16. For a newly active node, default to generating at least one useful interaction surface unless the current interactionHints already contain a strong form or actionable button group.
17. interactionPolicy.needsRefresh = true means the current node only has weak fallback UI and you should generate better interaction hints now.
18. The onboarding flow is:
- agentIdentity: establish your own name, nature, vibe, and emoji
- userIdentity: capture who the user is professionally
- workStyle: capture how they think, decide, and communicate
- workContext: capture current focus, interests, tools, and active projects
- painPoints: capture friction, blockers, and unmet needs
- responseLanguage
- proSettings
- summary
19. At the summary step, write like a person describing someone they just met, then give 3-5 concrete service suggestions.
Protocol:
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
- draft
- currentQuestion
3. Only advance the flow through:
- 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 a reliable draft already exists and the user is just confirming it.
6. When structured interaction would help, call askUserQuestion immediately and define the interaction yourself.
7. Never call a later node before the active step is complete, even if the user mentioned later-step information early.
8. Never finish onboarding before the summary step.
9. Do not claim onboarding data was saved unless the tool call succeeded.
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. saveAnswer patch is node-scoped. Because node is already provided, send only that node's fields instead of wrapping them under the node name.
7. Do not rely on placeholder questions from the server. Generate the exact interaction you want the user to see.
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.
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.
`.trim();

View File

@@ -1,10 +1,10 @@
export const WebOnboardingIdentifier = 'lobe-web-onboarding';
export const WebOnboardingApiName = {
commitOnboardingNode: 'commitOnboardingNode',
finishAgentOnboarding: 'finishAgentOnboarding',
getOnboardingContext: 'getOnboardingContext',
proposeOnboardingInteractions: 'proposeOnboardingInteractions',
proposeOnboardingPatch: 'proposeOnboardingPatch',
redirectOfftopic: 'redirectOfftopic',
askUserQuestion: 'askUserQuestion',
completeCurrentStep: 'completeCurrentStep',
finishOnboarding: 'finishOnboarding',
getOnboardingState: 'getOnboardingState',
returnToOnboarding: 'returnToOnboarding',
saveAnswer: 'saveAnswer',
} as const;

View File

@@ -15,7 +15,12 @@ export type UserAgentOnboardingNode = (typeof AGENT_ONBOARDING_NODES)[number];
export interface UserAgentOnboardingUpdate {
node: UserAgentOnboardingNode;
patch: UserAgentOnboardingDraft;
patch: Record<string, unknown>;
}
export interface UserOnboardingDefaultModel {
model: string;
provider: string;
}
export interface UserOnboardingAgentIdentity {
@@ -67,77 +72,65 @@ export interface UserOnboardingProfile {
workStyle?: UserOnboardingDimensionWorkStyle;
}
export interface UserAgentOnboardingInteractionFieldOption {
export interface UserAgentOnboardingQuestionFieldOption {
label: string;
value: string;
}
export interface UserAgentOnboardingInteractionField {
export interface UserAgentOnboardingQuestionField {
key: string;
kind: 'emoji' | 'multiselect' | 'select' | 'text' | 'textarea';
label: string;
options?: UserAgentOnboardingInteractionFieldOption[];
options?: UserAgentOnboardingQuestionFieldOption[];
placeholder?: string;
required?: boolean;
value?: string | string[];
}
export interface UserAgentOnboardingInteractionActionPayload {
export interface UserAgentOnboardingQuestionChoicePayload {
kind: 'message' | 'patch';
message?: string;
patch?: UserAgentOnboardingDraft;
patch?: Record<string, unknown>;
targetNode?: UserAgentOnboardingNode;
}
export interface UserAgentOnboardingInteractionAction {
export interface UserAgentOnboardingQuestionChoice {
id: string;
label: string;
payload?: UserAgentOnboardingInteractionActionPayload;
payload?: UserAgentOnboardingQuestionChoicePayload;
style?: 'danger' | 'default' | 'primary';
}
export interface UserAgentOnboardingInteractionHintDraft {
actions?: UserAgentOnboardingInteractionAction[];
export interface UserAgentOnboardingQuestionDraft {
choices?: UserAgentOnboardingQuestionChoice[];
description?: string;
fields?: UserAgentOnboardingInteractionField[];
fields?: UserAgentOnboardingQuestionField[];
id: string;
kind: 'button_group' | 'composer_prefill' | 'form' | 'info' | 'select';
metadata?: Record<string, unknown>;
mode: 'button_group' | 'composer_prefill' | 'form' | 'info' | 'select';
priority?: 'primary' | 'secondary';
prompt: string;
submitMode?: 'message' | 'tool';
title?: string;
}
export interface UserAgentOnboardingInteractionHint {
actions?: UserAgentOnboardingInteractionAction[];
description?: string;
fields?: UserAgentOnboardingInteractionField[];
id: string;
kind: 'button_group' | 'composer_prefill' | 'form' | 'info' | 'select';
metadata?: Record<string, unknown>;
export interface UserAgentOnboardingQuestion extends UserAgentOnboardingQuestionDraft {
node: UserAgentOnboardingNode;
priority?: 'primary' | 'secondary';
submitMode?: 'message' | 'tool';
title?: string;
}
export interface UserAgentOnboardingInteractionSurface {
hints: UserAgentOnboardingInteractionHint[];
export interface UserAgentOnboardingQuestionSurface {
node: UserAgentOnboardingNode;
question: UserAgentOnboardingQuestion;
updatedAt: string;
}
export interface UserAgentOnboardingDraft {
agentIdentity?: UserOnboardingAgentIdentity;
defaultModel?: {
model: string;
provider: string;
};
painPoints?: UserOnboardingDimensionPainPoints;
agentIdentity?: Partial<UserOnboardingAgentIdentity>;
defaultModel?: Partial<UserOnboardingDefaultModel>;
painPoints?: Partial<UserOnboardingDimensionPainPoints>;
responseLanguage?: string;
userIdentity?: UserOnboardingDimensionIdentity;
workContext?: UserOnboardingDimensionWorkContext;
workStyle?: UserOnboardingDimensionWorkStyle;
userIdentity?: Partial<UserOnboardingDimensionIdentity>;
workContext?: Partial<UserOnboardingDimensionWorkContext>;
workStyle?: Partial<UserOnboardingDimensionWorkStyle>;
}
export interface UserAgentOnboarding {
@@ -146,8 +139,8 @@ export interface UserAgentOnboarding {
completedNodes?: UserAgentOnboardingNode[];
draft?: UserAgentOnboardingDraft;
finishedAt?: string;
interactionSurface?: UserAgentOnboardingInteractionSurface;
profile?: UserOnboardingProfile;
questionSurface?: UserAgentOnboardingQuestionSurface;
version: number;
}
@@ -155,8 +148,6 @@ export const UserAgentOnboardingNodeSchema = z.enum(AGENT_ONBOARDING_NODES);
export const UserAgentOnboardingUpdateSchema = z.object({
node: UserAgentOnboardingNodeSchema,
// Keep unknown keys so the service can detect malformed node-scoped payloads
// and return a structured error instead of silently receiving an empty patch.
patch: z.object({}).passthrough(),
});
@@ -200,71 +191,71 @@ const UserOnboardingDimensionPainPointsSchema = z.object({
summary: z.string(),
});
const UserAgentOnboardingInteractionFieldOptionSchema = z.object({
const UserAgentOnboardingQuestionFieldOptionSchema = z.object({
label: z.string(),
value: z.string(),
});
const UserAgentOnboardingInteractionFieldSchema = z.object({
const UserAgentOnboardingQuestionFieldSchema = z.object({
key: z.string(),
kind: z.enum(['emoji', 'multiselect', 'select', 'text', 'textarea']),
label: z.string(),
options: z.array(UserAgentOnboardingInteractionFieldOptionSchema).optional(),
options: z.array(UserAgentOnboardingQuestionFieldOptionSchema).optional(),
placeholder: z.string().optional(),
required: z.boolean().optional(),
value: z.union([z.string(), z.array(z.string())]).optional(),
});
const UserAgentOnboardingInteractionActionPayloadSchema = z.object({
const UserAgentOnboardingQuestionChoicePayloadSchema = z.object({
kind: z.enum(['message', 'patch']),
message: z.string().optional(),
patch: z.lazy(() => UserAgentOnboardingDraftSchema).optional(),
patch: z.record(z.string(), z.unknown()).optional(),
targetNode: UserAgentOnboardingNodeSchema.optional(),
});
const UserAgentOnboardingInteractionActionSchema = z.object({
const UserAgentOnboardingQuestionChoiceSchema = z.object({
id: z.string(),
label: z.string(),
payload: UserAgentOnboardingInteractionActionPayloadSchema.optional(),
payload: UserAgentOnboardingQuestionChoicePayloadSchema.optional(),
style: z.enum(['danger', 'default', 'primary']).optional(),
});
export const UserAgentOnboardingInteractionHintDraftSchema = z.object({
actions: z.array(UserAgentOnboardingInteractionActionSchema).optional(),
export const UserAgentOnboardingQuestionDraftSchema = z.object({
choices: z.array(UserAgentOnboardingQuestionChoiceSchema).optional(),
description: z.string().optional(),
fields: z.array(UserAgentOnboardingInteractionFieldSchema).optional(),
fields: z.array(UserAgentOnboardingQuestionFieldSchema).optional(),
id: z.string(),
kind: z.enum(['button_group', 'composer_prefill', 'form', 'info', 'select']),
metadata: z.record(z.string(), z.unknown()).optional(),
mode: z.enum(['button_group', 'composer_prefill', 'form', 'info', 'select']),
priority: z.enum(['primary', 'secondary']).optional(),
prompt: z.string(),
submitMode: z.enum(['message', 'tool']).optional(),
title: z.string().optional(),
});
const UserAgentOnboardingInteractionHintSchema =
UserAgentOnboardingInteractionHintDraftSchema.extend({
node: UserAgentOnboardingNodeSchema,
});
const UserAgentOnboardingInteractionSurfaceSchema = z.object({
hints: z.array(UserAgentOnboardingInteractionHintSchema),
const UserAgentOnboardingQuestionSchema = UserAgentOnboardingQuestionDraftSchema.extend({
node: UserAgentOnboardingNodeSchema,
updatedAt: z.string(),
});
export const UserAgentOnboardingDraftSchema = z.object({
agentIdentity: UserOnboardingAgentIdentitySchema.optional(),
agentIdentity: UserOnboardingAgentIdentitySchema.partial().optional(),
defaultModel: z
.object({
model: z.string(),
provider: z.string(),
})
.partial()
.optional(),
painPoints: UserOnboardingDimensionPainPointsSchema.optional(),
painPoints: UserOnboardingDimensionPainPointsSchema.partial().optional(),
responseLanguage: z.string().optional(),
userIdentity: UserOnboardingDimensionIdentitySchema.optional(),
workContext: UserOnboardingDimensionWorkContextSchema.optional(),
workStyle: UserOnboardingDimensionWorkStyleSchema.optional(),
userIdentity: UserOnboardingDimensionIdentitySchema.partial().optional(),
workContext: UserOnboardingDimensionWorkContextSchema.partial().optional(),
workStyle: UserOnboardingDimensionWorkStyleSchema.partial().optional(),
});
const UserAgentOnboardingQuestionSurfaceSchema = z.object({
node: UserAgentOnboardingNodeSchema,
question: UserAgentOnboardingQuestionSchema,
updatedAt: z.string(),
});
export const UserAgentOnboardingSchema = z.object({
@@ -273,7 +264,6 @@ export const UserAgentOnboardingSchema = z.object({
completedNodes: z.array(UserAgentOnboardingNodeSchema).optional(),
draft: UserAgentOnboardingDraftSchema.optional(),
finishedAt: z.string().optional(),
interactionSurface: UserAgentOnboardingInteractionSurfaceSchema.optional(),
profile: z
.object({
currentFocus: z.string().optional(),
@@ -284,5 +274,6 @@ export const UserAgentOnboardingSchema = z.object({
workStyle: UserOnboardingDimensionWorkStyleSchema.optional(),
})
.optional(),
questionSurface: UserAgentOnboardingQuestionSurfaceSchema.optional(),
version: z.number(),
});

View File

@@ -16,19 +16,20 @@ vi.mock('@/features/Conversation', () => ({
ChatList: ({ welcome }: { welcome?: any }) => <div data-testid="chat-list">{welcome}</div>,
}));
vi.mock('./InteractionHintRenderer', () => ({
vi.mock('./QuestionRenderer', () => ({
default: ({
interactionHints,
currentQuestion,
onDismissNode,
}: {
interactionHints?: Array<{ id: string; node?: string }>;
currentQuestion?: { id: string; node?: string; prompt?: string };
onDismissNode?: (node: string) => void;
}) => (
<div data-testid="structured-actions">
<div>{interactionHints?.map((hint) => hint.id).join(',')}</div>
<div>{interactionHints?.[0]?.node}</div>
{interactionHints?.[0] && (
<button onClick={() => onDismissNode?.(interactionHints[0].node!)}>dismiss</button>
<div>{currentQuestion?.id}</div>
<div>{currentQuestion?.node}</div>
<div>{currentQuestion?.prompt}</div>
{currentQuestion && (
<button onClick={() => onDismissNode?.(currentQuestion.node!)}>dismiss</button>
)}
</div>
),
@@ -42,13 +43,14 @@ describe('AgentOnboardingConversation', () => {
it('renders structured actions and disables expand + runtime config in chat input', () => {
render(
<AgentOnboardingConversation
interactionHints={[
currentQuestion={
{
id: 'response-language-select',
kind: 'select',
id: 'response-language-question',
mode: 'select',
node: 'responseLanguage',
} as any,
]}
prompt: '你希望我默认用什么语言回复你?',
} as any
}
/>,
);
@@ -64,26 +66,36 @@ describe('AgentOnboardingConversation', () => {
);
});
it('hides the current interaction hint after it is dismissed locally', () => {
it('passes the current question to the renderer', () => {
render(
<AgentOnboardingConversation
interactionHints={[
currentQuestion={
{
id: 'agent-identity-form',
kind: 'form',
id: 'agent-identity-question',
mode: 'form',
node: 'agentIdentity',
} as any,
{
id: 'agent-identity-presets',
kind: 'button_group',
node: 'agentIdentity',
} as any,
]}
prompt: '先把我定下来吧。',
} as any
}
/>,
);
expect(screen.getByTestId('structured-actions')).toHaveTextContent(
'agent-identity-form,agent-identity-presets',
expect(screen.getByTestId('structured-actions')).toHaveTextContent('agent-identity-question');
expect(screen.getByTestId('structured-actions')).toHaveTextContent('先把我定下来吧。');
});
it('hides the current question after it is dismissed locally', () => {
render(
<AgentOnboardingConversation
currentQuestion={
{
id: 'agent-identity-question',
mode: 'form',
node: 'agentIdentity',
prompt: '先把我定下来吧。',
} as any
}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'dismiss' }));

View File

@@ -1,9 +1,6 @@
'use client';
import {
type UserAgentOnboardingInteractionHint,
type UserAgentOnboardingUpdate,
} from '@lobechat/types';
import { type UserAgentOnboardingQuestion } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
@@ -12,7 +9,7 @@ import { type ActionKeys } from '@/features/ChatInput';
import { ChatInput, ChatList } from '@/features/Conversation';
import { isDev } from '@/utils/env';
import InteractionHintRenderer from './InteractionHintRenderer';
import QuestionRenderer from './QuestionRenderer';
import Welcome from './Welcome';
const useStyles = createStyles(({ css, token }) => ({
@@ -30,37 +27,37 @@ const useStyles = createStyles(({ css, token }) => ({
}));
interface AgentOnboardingConversationProps {
interactionHints?: UserAgentOnboardingInteractionHint[];
onSubmitInteractionUpdates?: (updates: UserAgentOnboardingUpdate[]) => Promise<void>;
currentQuestion?: UserAgentOnboardingQuestion;
}
const chatInputLeftActions: ActionKeys[] = isDev ? ['model'] : [];
const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
({ interactionHints, onSubmitInteractionUpdates }) => {
({ currentQuestion }) => {
const { styles } = useStyles();
const [dismissedNodes, setDismissedNodes] = useState<string[]>([]);
const interactionSignature = useMemo(
() => JSON.stringify(interactionHints || []),
[interactionHints],
const questionSignature = useMemo(
() => JSON.stringify(currentQuestion || null),
[currentQuestion],
);
const lastInteractionSignatureRef = useRef(interactionSignature);
const lastQuestionSignatureRef = useRef(questionSignature);
useEffect(() => {
if (lastInteractionSignatureRef.current === interactionSignature) return;
if (lastQuestionSignatureRef.current === questionSignature) return;
lastInteractionSignatureRef.current = interactionSignature;
lastQuestionSignatureRef.current = questionSignature;
setDismissedNodes([]);
}, [interactionSignature]);
}, [questionSignature]);
const visibleInteractionHints = useMemo(() => {
if (!interactionHints?.length) return [];
const visibleQuestion = useMemo(() => {
if (!currentQuestion) return undefined;
const dismissedNodeSet = new Set(dismissedNodes);
return interactionHints.filter((hint) => !dismissedNodeSet.has(hint.node));
}, [dismissedNodes, interactionHints]);
const showStructuredActions = visibleInteractionHints.length > 0;
return dismissedNodeSet.has(currentQuestion.node) ? undefined : currentQuestion;
}, [currentQuestion, dismissedNodes]);
const showQuestionSurface = !!visibleQuestion;
const handleDismissNode = (node: string) => {
setDismissedNodes((state) => (state.includes(node) ? state : [...state, node]));
@@ -82,12 +79,11 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
</Flexbox>
<Flexbox className={styles.composerZone} paddingInline={8}>
{showStructuredActions && (
{showQuestionSurface && visibleQuestion && (
<Flexbox className={styles.structuredActions}>
<InteractionHintRenderer
interactionHints={visibleInteractionHints}
<QuestionRenderer
currentQuestion={visibleQuestion}
onDismissNode={handleDismissNode}
onSubmitUpdates={onSubmitInteractionUpdates}
/>
</Flexbox>
)}

View File

@@ -1,210 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import InteractionHintRenderer from './InteractionHintRenderer';
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('InteractionHintRenderer', () => {
it('renders button group hints and forwards message actions', async () => {
const onDismissNode = vi.fn();
render(
<InteractionHintRenderer
interactionHints={[
{
actions: [
{
id: 'preset',
label: 'Warm + curious',
payload: {
kind: 'message',
message: 'hello from hint',
},
style: 'primary',
},
],
id: 'hint-1',
kind: 'button_group',
node: 'agentIdentity',
title: 'Quick presets',
},
]}
onDismissNode={onDismissNode}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Warm + curious' }));
expect(sendMessage).toHaveBeenCalledWith({ message: 'hello from hint' });
await waitFor(() => {
expect(onDismissNode).toHaveBeenCalledWith('agentIdentity');
});
});
it('falls back to sending the action label when a message button has no payload', async () => {
const onDismissNode = vi.fn();
render(
<InteractionHintRenderer
interactionHints={[
{
actions: [
{
id: 'identity-ai-builder',
label: 'AI 产品开发者',
style: 'default',
},
],
id: 'identity-quick-pick',
kind: 'button_group',
node: 'userIdentity',
submitMode: 'message',
title: '选一个最贴切的身份标签',
},
]}
onDismissNode={onDismissNode}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'AI 产品开发者' }));
expect(sendMessage).toHaveBeenCalledWith({ message: 'AI 产品开发者' });
await waitFor(() => {
expect(onDismissNode).toHaveBeenCalledWith('userIdentity');
});
});
it('routes patch actions to the clicked hint node instead of the first hint node', async () => {
const onSubmitUpdates = vi.fn();
render(
<InteractionHintRenderer
interactionHints={[
{
actions: [
{
id: 'preset',
label: 'Warm + curious',
payload: {
kind: 'message',
message: 'hello from hint',
},
style: 'primary',
},
],
id: 'hint-1',
kind: 'button_group',
node: 'agentIdentity',
title: 'Quick presets',
},
{
actions: [
{
id: 'set-language',
label: 'Use Chinese',
payload: {
kind: 'patch',
patch: {
responseLanguage: 'zh-CN',
},
},
style: 'default',
},
],
id: 'hint-2',
kind: 'button_group',
node: 'responseLanguage',
title: 'Language',
},
]}
onSubmitUpdates={onSubmitUpdates}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Use Chinese' }));
expect(onSubmitUpdates).toHaveBeenCalledWith([
{
node: 'responseLanguage',
patch: {
responseLanguage: 'zh-CN',
},
},
]);
});
});

View File

@@ -1,518 +0,0 @@
'use client';
import type {
UserAgentOnboardingDraft,
UserAgentOnboardingInteractionAction,
UserAgentOnboardingInteractionField,
UserAgentOnboardingInteractionHint,
UserAgentOnboardingNode,
UserAgentOnboardingUpdate,
} from '@lobechat/types';
import { Button, Flexbox, Input, Select, Text } from '@lobehub/ui';
import { Input as AntdInput } from 'antd';
import { type ChangeEvent, memo, useEffect, useMemo, useState } 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 { isDev } from '@/utils/env';
import { ONBOARDING_PRODUCTION_DEFAULT_MODEL } from '../../../const/onboarding';
type FormValue = string | string[];
interface InteractionHintRendererProps {
interactionHints: UserAgentOnboardingInteractionHint[];
onDismissNode?: (node: UserAgentOnboardingNode) => void;
onSubmitUpdates?: (updates: UserAgentOnboardingUpdate[]) => Promise<void>;
}
const getActionMessage = (
action: UserAgentOnboardingInteractionAction,
hint: UserAgentOnboardingInteractionHint,
) => {
if (action.payload?.kind === 'message') {
return action.payload.message || action.label || undefined;
}
if (hint.submitMode === 'message' && action.label) {
return action.label;
}
return undefined;
};
const buildPatchFromFields = (
node: UserAgentOnboardingNode,
values: Record<string, FormValue>,
): UserAgentOnboardingDraft | undefined => {
switch (node) {
case 'agentIdentity': {
return {
agentIdentity: {
emoji: String(values.emoji || ''),
name: String(values.name || ''),
nature: String(values.nature || ''),
vibe: String(values.vibe || ''),
},
};
}
case 'userIdentity': {
return {
userIdentity: {
domainExpertise: String(values.domainExpertise || ''),
name: String(values.name || ''),
professionalRole: String(values.professionalRole || ''),
summary: String(values.summary || ''),
},
};
}
case 'workStyle': {
return {
workStyle: {
communicationStyle: String(values.communicationStyle || ''),
decisionMaking: String(values.decisionMaking || ''),
socialMode: String(values.socialMode || ''),
summary: String(values.summary || ''),
thinkingPreferences: String(values.thinkingPreferences || ''),
workStyle: String(values.workStyle || ''),
},
};
}
case 'workContext': {
return {
workContext: {
activeProjects: Array.isArray(values.activeProjects) ? values.activeProjects : undefined,
currentFocus: String(values.currentFocus || ''),
interests: Array.isArray(values.interests) ? values.interests : undefined,
summary: String(values.summary || ''),
thisQuarter: String(values.thisQuarter || ''),
thisWeek: String(values.thisWeek || ''),
tools: Array.isArray(values.tools) ? values.tools : undefined,
},
};
}
case 'painPoints': {
return {
painPoints: {
blockedBy: Array.isArray(values.blockedBy) ? values.blockedBy : undefined,
frustrations: Array.isArray(values.frustrations) ? values.frustrations : undefined,
noTimeFor: Array.isArray(values.noTimeFor) ? values.noTimeFor : undefined,
summary: String(values.summary || ''),
},
};
}
case 'responseLanguage': {
return {
responseLanguage: String(values.responseLanguage || ''),
};
}
case 'proSettings': {
return undefined;
}
case 'summary': {
return undefined;
}
}
};
const renderFieldControl = (
field: UserAgentOnboardingInteractionField,
value: FormValue,
onChange: (nextValue: FormValue) => void,
) => {
switch (field.kind) {
case 'emoji': {
return (
<EmojiPicker
value={typeof value === 'string' ? value || undefined : undefined}
onChange={(emoji) => onChange(emoji || '')}
/>
);
}
case 'multiselect': {
return (
<Select
mode={'multiple'}
options={field.options}
placeholder={field.placeholder}
value={Array.isArray(value) ? value : []}
onChange={(nextValue) => onChange(nextValue)}
/>
);
}
case 'select': {
return (
<Select
options={field.options}
placeholder={field.placeholder}
value={typeof value === 'string' ? value : undefined}
onChange={(nextValue) => onChange(nextValue)}
/>
);
}
case 'textarea': {
return (
<AntdInput.TextArea
placeholder={field.placeholder}
rows={3}
value={typeof value === 'string' ? value : ''}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
/>
);
}
case 'text': {
return (
<Input
placeholder={field.placeholder}
value={typeof value === 'string' ? value : ''}
onChange={(event) => onChange(event.target.value)}
/>
);
}
}
};
const HintHeader = memo<Pick<UserAgentOnboardingInteractionHint, 'description' | 'title'>>(
({ description, title }) => {
if (!title && !description) return null;
return (
<Flexbox gap={4}>
{title && <Text weight={'bold'}>{title}</Text>}
{description && <Text type={'secondary'}>{description}</Text>}
</Flexbox>
);
},
);
HintHeader.displayName = 'HintHeader';
const HintButtonGroup = memo<{
hint: UserAgentOnboardingInteractionHint;
loading: boolean;
onAction: (
action: UserAgentOnboardingInteractionAction,
hint: UserAgentOnboardingInteractionHint,
) => Promise<void>;
}>(({ hint, loading, onAction }) => (
<Flexbox gap={12}>
<HintHeader description={hint.description} title={hint.title} />
<Flexbox horizontal gap={8} wrap={'wrap'}>
{(hint.actions || []).map((action) => (
<Button
danger={action.style === 'danger'}
disabled={loading}
key={action.id}
type={action.style === 'primary' ? 'primary' : 'default'}
onClick={() => void onAction(action, hint)}
>
{action.label}
</Button>
))}
</Flexbox>
</Flexbox>
));
HintButtonGroup.displayName = 'HintButtonGroup';
const HintForm = memo<{
hint: UserAgentOnboardingInteractionHint;
loading: boolean;
onDismissNode?: (node: UserAgentOnboardingNode) => void;
onSubmitUpdates?: (updates: UserAgentOnboardingUpdate[]) => Promise<void>;
}>(({ hint, loading, onDismissNode, onSubmitUpdates }) => {
const sendMessage = useConversationStore((s) => s.sendMessage);
const initialValues = useMemo(
() =>
Object.fromEntries(
(hint.fields || []).map((field) => [
field.key,
field.value ?? (field.kind === 'multiselect' ? [] : ''),
]),
) as Record<string, FormValue>,
[hint.fields],
);
const [values, setValues] = useState<Record<string, FormValue>>(initialValues);
useEffect(() => {
setValues(initialValues);
}, [initialValues]);
const handleSubmit = async () => {
const patch = buildPatchFromFields(hint.node, values);
if (!patch) return;
if (hint.submitMode === 'tool' && onSubmitUpdates) {
await onSubmitUpdates([{ node: hint.node, patch }]);
onDismissNode?.(hint.node);
return;
}
await sendMessage({
message: JSON.stringify({ node: hint.node, patch }),
});
onDismissNode?.(hint.node);
};
return (
<Flexbox gap={12}>
<HintHeader description={hint.description} title={hint.title} />
{(hint.fields || []).map((field) => (
<Flexbox gap={6} key={field.key}>
<Text type={'secondary'}>{field.label}</Text>
{renderFieldControl(field, values[field.key] ?? '', (nextValue) =>
setValues((state) => ({ ...state, [field.key]: nextValue })),
)}
</Flexbox>
))}
<Button disabled={loading} type={'primary'} onClick={() => void handleSubmit()}>
Submit
</Button>
</Flexbox>
);
});
HintForm.displayName = 'HintForm';
const HintSelect = memo<{
hint: UserAgentOnboardingInteractionHint;
loading: boolean;
onDismissNode?: (node: UserAgentOnboardingNode) => void;
onSubmitUpdates?: (updates: UserAgentOnboardingUpdate[]) => Promise<void>;
}>(({ hint, loading, onDismissNode, onSubmitUpdates }) => {
const { t } = useTranslation('onboarding');
const switchLocale = useGlobalStore((s) => s.switchLocale);
const sendMessage = useConversationStore((s) => s.sendMessage);
const currentResponseLanguage = useUserStore(
userGeneralSettingsSelectors.currentResponseLanguage,
);
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
const field = hint.fields?.[0];
const initialValue = (typeof field?.value === 'string' && field.value) || currentResponseLanguage;
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const options =
field?.options || (hint.metadata?.optionsSource === 'clientLocaleOptions' ? localeOptions : []);
const handleSubmit = async () => {
if (hint.submitMode === 'tool' && onSubmitUpdates) {
await onSubmitUpdates([
{
node: hint.node,
patch: {
responseLanguage: value,
},
},
]);
onDismissNode?.(hint.node);
return;
}
await sendMessage({
message: `Set my default response language to ${value || 'auto'}.`,
});
onDismissNode?.(hint.node);
};
return (
<Flexbox gap={12}>
<HintHeader description={hint.description} title={hint.title} />
<Select
options={options}
size={'large'}
style={{ width: '100%' }}
value={value}
onChange={(nextValue) => {
switchLocale(nextValue);
setValue(nextValue);
void updateGeneralConfig({ responseLanguage: nextValue });
}}
/>
<Button disabled={loading} type={'primary'} onClick={() => void handleSubmit()}>
{t('next')}
</Button>
</Flexbox>
);
});
HintSelect.displayName = 'HintSelect';
const HintInfo = memo<{
hint: UserAgentOnboardingInteractionHint;
loading: boolean;
onDismissNode?: (node: UserAgentOnboardingNode) => void;
}>(({ hint, loading, onDismissNode }) => {
const { t } = useTranslation('onboarding');
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
const defaultAgentConfig = useUserStore(
(s) => settingsSelectors.currentSettings(s).defaultAgent?.config,
);
const updateDefaultModel = useUserStore((s) => s.updateDefaultModel);
const sendMessage = useConversationStore((s) => s.sendMessage);
const modelConfig = isDev ? defaultAgentConfig : ONBOARDING_PRODUCTION_DEFAULT_MODEL;
if (hint.metadata?.recommendedSurface !== 'modelPicker') {
return (
<Flexbox gap={8}>
<HintHeader description={hint.description} title={hint.title} />
</Flexbox>
);
}
const handleContinue = async () => {
if (!isDev) {
await updateDefaultModel(
ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
);
}
await sendMessage({
message:
modelConfig?.model && modelConfig.provider
? `I am done with advanced setup. Keep my default model as ${modelConfig.provider}/${modelConfig.model}.`
: 'I am done with advanced setup.',
});
onDismissNode?.(hint.node);
};
return (
<Flexbox gap={16}>
<HintHeader description={hint.description} title={hint.title} />
{isDev ? (
<ModelSelect
showAbility={false}
size={'large'}
style={{ width: '100%' }}
value={defaultAgentConfig}
onChange={({ model, provider }) => {
updateDefaultModel(model, provider);
}}
/>
) : (
<Text type={'secondary'}>
{t('proSettings.model.fixed', {
model: ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
provider: ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
})}
</Text>
)}
{enableKlavis && <KlavisServerList />}
<Button disabled={loading} type={'primary'} onClick={() => void handleContinue()}>
{t('next')}
</Button>
</Flexbox>
);
});
HintInfo.displayName = 'HintInfo';
const HintComposerPrefill = memo<{ hint: UserAgentOnboardingInteractionHint }>(({ hint }) => (
<Flexbox gap={8}>
<HintHeader description={hint.description} title={hint.title} />
</Flexbox>
));
HintComposerPrefill.displayName = 'HintComposerPrefill';
const InteractionHintRenderer = memo<InteractionHintRendererProps>(
({ interactionHints, onDismissNode, onSubmitUpdates }) => {
const loading = useConversationStore(messageStateSelectors.isInputLoading);
const sendMessage = useConversationStore((s) => s.sendMessage);
const handleAction = async (
action: UserAgentOnboardingInteractionAction,
hint: UserAgentOnboardingInteractionHint,
) => {
if (action.payload?.kind === 'patch' && action.payload.patch && onSubmitUpdates) {
await onSubmitUpdates([
{
node: action.payload.targetNode || hint.node,
patch: action.payload.patch,
},
]);
onDismissNode?.(hint.node);
return;
}
const message = getActionMessage(action, hint);
if (message) {
await sendMessage({ message });
onDismissNode?.(hint.node);
}
};
return (
<Flexbox gap={16}>
{interactionHints.map((hint) => {
switch (hint.kind) {
case 'button_group': {
return (
<HintButtonGroup
hint={hint}
key={hint.id}
loading={loading}
onAction={handleAction}
/>
);
}
case 'form': {
return (
<HintForm
hint={hint}
key={hint.id}
loading={loading}
onDismissNode={onDismissNode}
onSubmitUpdates={onSubmitUpdates}
/>
);
}
case 'select': {
return (
<HintSelect
hint={hint}
key={hint.id}
loading={loading}
onDismissNode={onDismissNode}
onSubmitUpdates={onSubmitUpdates}
/>
);
}
case 'info': {
return (
<HintInfo
hint={hint}
key={hint.id}
loading={loading}
onDismissNode={onDismissNode}
/>
);
}
case 'composer_prefill': {
return <HintComposerPrefill hint={hint} key={hint.id} />;
}
}
})}
</Flexbox>
);
},
);
InteractionHintRenderer.displayName = 'InteractionHintRenderer';
export default InteractionHintRenderer;

View File

@@ -0,0 +1,290 @@
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', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders button group questions and forwards message choices', async () => {
const onDismissNode = vi.fn();
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' }));
expect(sendMessage).toHaveBeenCalledWith({ message: 'hello from hint' });
await waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(onDismissNode).toHaveBeenCalledWith('agentIdentity');
});
});
it('falls back to sending the action label when a message button has no payload', async () => {
render(
<QuestionRenderer
currentQuestion={{
choices: [
{
id: 'identity-ai-builder',
label: 'AI 产品开发者',
style: 'default',
},
],
id: 'identity-quick-pick',
mode: 'button_group',
node: 'userIdentity',
prompt: '选一个最贴切的身份标签',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'AI 产品开发者' }));
expect(sendMessage).toHaveBeenCalledWith({ message: 'AI 产品开发者' });
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
});
it('falls back to the action label for patch-style actions', async () => {
render(
<QuestionRenderer
currentQuestion={{
choices: [
{
id: 'set-language',
label: 'Use Chinese',
payload: {
kind: 'patch',
patch: {
responseLanguage: 'zh-CN',
},
},
style: 'default',
},
],
id: 'question-2',
mode: 'button_group',
node: 'responseLanguage',
prompt: 'Language',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Use Chinese' }));
expect(sendMessage).toHaveBeenCalledWith({
message: 'Use Chinese',
});
});
it('formats form submissions as question-answer text', async () => {
const onDismissNode = vi.fn();
render(
<QuestionRenderer
currentQuestion={{
fields: [
{
key: 'professionalRole',
kind: 'text',
label: 'Role',
placeholder: 'Your role',
value: '',
},
{
key: 'name',
kind: 'text',
label: 'Name',
placeholder: 'Your name',
value: '',
},
],
id: 'user-identity-form',
mode: 'form',
node: 'userIdentity',
prompt: 'About you',
}}
onDismissNode={onDismissNode}
/>,
);
fireEvent.change(screen.getByPlaceholderText('Your role'), {
target: { value: 'Independent developer' },
});
fireEvent.change(screen.getByPlaceholderText('Your name'), {
target: { value: 'Ada' },
});
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(sendMessage).toHaveBeenCalledWith({
message: ['Q: Role', 'A: Independent developer', '', 'Q: Name', 'A: Ada'].join('\n'),
});
await waitFor(() => {
expect(onDismissNode).toHaveBeenCalledWith('userIdentity');
});
});
it('submits the form when pressing Enter in a text input', async () => {
render(
<QuestionRenderer
currentQuestion={{
fields: [
{
key: 'professionalRole',
kind: 'text',
label: 'Role',
placeholder: 'Your role',
value: '',
},
],
id: 'user-identity-form',
mode: 'form',
node: 'userIdentity',
prompt: 'About you',
}}
/>,
);
fireEvent.change(screen.getByPlaceholderText('Your role'), {
target: { value: 'Independent developer' },
});
fireEvent.keyDown(screen.getByPlaceholderText('Your role'), {
code: 'Enter',
key: 'Enter',
});
expect(sendMessage).toHaveBeenCalledWith({
message: ['Q: Role', 'A: Independent developer'].join('\n'),
});
});
it('formats select submissions as question-answer text', async () => {
render(
<QuestionRenderer
currentQuestion={{
fields: [
{
key: 'responseLanguage',
kind: 'select',
label: 'Response language',
options: [
{ label: 'English', value: 'en-US' },
{ label: 'Chinese', value: 'zh-CN' },
],
value: 'en-US',
},
],
id: 'response-language-select',
mode: 'select',
node: 'responseLanguage',
prompt: 'Language',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'next' }));
expect(sendMessage).toHaveBeenCalledWith({
message: ['Q: Response language', 'A: English'].join('\n'),
});
});
});

View File

@@ -0,0 +1,424 @@
'use client';
import type {
UserAgentOnboardingNode,
UserAgentOnboardingQuestion,
UserAgentOnboardingQuestionChoice,
UserAgentOnboardingQuestionField,
} from '@lobechat/types';
import { Button, Flexbox, Input, Select, Text } from '@lobehub/ui';
import { Input as AntdInput } from 'antd';
import { type ChangeEvent, memo, useEffect, useMemo, useState } 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 { isDev } from '@/utils/env';
import { ONBOARDING_PRODUCTION_DEFAULT_MODEL } from '../../../const/onboarding';
type FormValue = string | string[];
interface QuestionRendererProps {
currentQuestion: UserAgentOnboardingQuestion;
onDismissNode?: (node: UserAgentOnboardingNode) => void;
}
const getChoiceMessage = (choice: UserAgentOnboardingQuestionChoice) => {
if (choice.payload?.kind === 'message') {
return choice.payload.message || choice.label || undefined;
}
if (choice.label) {
return choice.label;
}
return undefined;
};
const resolveFieldAnswer = (
field: UserAgentOnboardingQuestionField,
value: FormValue | undefined,
) => {
if (Array.isArray(value)) {
const optionLabels = value
.map((item) => field.options?.find((option) => option.value === item)?.label || item)
.filter(Boolean);
return optionLabels.length > 0 ? optionLabels.join(', ') : undefined;
}
const normalizedValue = String(value || '').trim();
if (!normalizedValue) return undefined;
return (
field.options?.find((option) => option.value === normalizedValue)?.label || normalizedValue
);
};
const buildQuestionAnswerMessage = (
fields: UserAgentOnboardingQuestionField[] | undefined,
values: Record<string, FormValue>,
) => {
const lines =
fields
?.map((field) => {
const answer = resolveFieldAnswer(field, values[field.key]);
if (!answer) return undefined;
return `Q: ${field.label}\nA: ${answer}`;
})
.filter((line): line is string => Boolean(line)) || [];
return lines.length > 0 ? lines.join('\n\n') : undefined;
};
const renderFieldControl = (
field: UserAgentOnboardingQuestionField,
value: FormValue,
onChange: (nextValue: FormValue) => void,
onSubmit?: () => void,
) => {
switch (field.kind) {
case 'emoji': {
return (
<EmojiPicker
value={typeof value === 'string' ? value || undefined : undefined}
onChange={(emoji) => onChange(emoji || '')}
/>
);
}
case 'multiselect': {
return (
<Select
mode={'multiple'}
options={field.options}
placeholder={field.placeholder}
value={Array.isArray(value) ? value : []}
onChange={(nextValue) => onChange(nextValue)}
/>
);
}
case 'select': {
return (
<Select
options={field.options}
placeholder={field.placeholder}
value={typeof value === 'string' ? value : undefined}
onChange={(nextValue) => onChange(nextValue)}
/>
);
}
case 'textarea': {
return (
<AntdInput.TextArea
placeholder={field.placeholder}
rows={3}
value={typeof value === 'string' ? value : ''}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
/>
);
}
case 'text': {
return (
<Input
placeholder={field.placeholder}
value={typeof value === 'string' ? value : ''}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
onSubmit?.();
}}
/>
);
}
}
};
const QuestionHeader = memo<Pick<UserAgentOnboardingQuestion, 'description' | 'prompt'>>(
({ description, prompt }) => {
if (!prompt && !description) return null;
return (
<Flexbox gap={4}>
{prompt && <Text weight={'bold'}>{prompt}</Text>}
{description && <Text type={'secondary'}>{description}</Text>}
</Flexbox>
);
},
);
QuestionHeader.displayName = 'QuestionHeader';
const QuestionChoices = memo<{
loading: boolean;
onChoose: (choice: UserAgentOnboardingQuestionChoice) => Promise<void>;
question: UserAgentOnboardingQuestion;
}>(({ loading, onChoose, question }) => (
<Flexbox gap={12}>
<QuestionHeader description={question.description} prompt={question.prompt} />
<Flexbox horizontal gap={8} wrap={'wrap'}>
{(question.choices || []).map((choice) => (
<Button
danger={choice.style === 'danger'}
disabled={loading}
key={choice.id}
type={choice.style === 'primary' ? 'primary' : 'default'}
onClick={() => void onChoose(choice)}
>
{choice.label}
</Button>
))}
</Flexbox>
</Flexbox>
));
QuestionChoices.displayName = 'QuestionChoices';
const QuestionForm = memo<{
loading: boolean;
onDismissNode?: (node: UserAgentOnboardingNode) => void;
question: UserAgentOnboardingQuestion;
}>(({ loading, onDismissNode, question }) => {
const sendMessage = useConversationStore((s) => s.sendMessage);
const initialValues = useMemo(
() =>
Object.fromEntries(
(question.fields || []).map((field) => [
field.key,
field.value ?? (field.kind === 'multiselect' ? [] : ''),
]),
) as Record<string, FormValue>,
[question.fields],
);
const [values, setValues] = useState<Record<string, FormValue>>(initialValues);
useEffect(() => {
setValues(initialValues);
}, [initialValues]);
const handleSubmit = async () => {
const message = buildQuestionAnswerMessage(question.fields, values);
if (!message) return;
await sendMessage({
message,
});
onDismissNode?.(question.node);
};
return (
<Flexbox gap={12}>
<QuestionHeader description={question.description} prompt={question.prompt} />
{(question.fields || []).map((field) => (
<Flexbox gap={6} key={field.key}>
<Text type={'secondary'}>{field.label}</Text>
{renderFieldControl(
field,
values[field.key] ?? '',
(nextValue) => setValues((state) => ({ ...state, [field.key]: nextValue })),
() => void handleSubmit(),
)}
</Flexbox>
))}
<Button disabled={loading} type={'primary'} onClick={() => void handleSubmit()}>
Submit
</Button>
</Flexbox>
);
});
QuestionForm.displayName = 'QuestionForm';
const QuestionSelect = memo<{
loading: boolean;
onDismissNode?: (node: UserAgentOnboardingNode) => void;
question: UserAgentOnboardingQuestion;
}>(({ loading, onDismissNode, question }) => {
const { t } = useTranslation('onboarding');
const switchLocale = useGlobalStore((s) => s.switchLocale);
const sendMessage = useConversationStore((s) => s.sendMessage);
const currentResponseLanguage = useUserStore(
userGeneralSettingsSelectors.currentResponseLanguage,
);
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
const field = question.fields?.[0];
const initialValue = (typeof field?.value === 'string' && field.value) || currentResponseLanguage;
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const options =
field?.options ||
(question.metadata?.optionsSource === 'clientLocaleOptions' ? localeOptions : []);
const handleSubmit = async () => {
const message = buildQuestionAnswerMessage(field ? [{ ...field, options }] : undefined, {
[field?.key || 'responseLanguage']: value,
});
if (!message) return;
await sendMessage({
message,
});
onDismissNode?.(question.node);
};
return (
<Flexbox gap={12}>
<QuestionHeader description={question.description} prompt={question.prompt} />
<Select
options={options}
size={'large'}
style={{ width: '100%' }}
value={value}
onChange={(nextValue) => {
switchLocale(nextValue);
setValue(nextValue);
void updateGeneralConfig({ responseLanguage: nextValue });
}}
/>
<Button disabled={loading} type={'primary'} onClick={() => void handleSubmit()}>
{t('next')}
</Button>
</Flexbox>
);
});
QuestionSelect.displayName = 'QuestionSelect';
const QuestionInfo = memo<{
loading: boolean;
onDismissNode?: (node: UserAgentOnboardingNode) => void;
question: UserAgentOnboardingQuestion;
}>(({ loading, onDismissNode, question }) => {
const { t } = useTranslation('onboarding');
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
const defaultAgentConfig = useUserStore(
(s) => settingsSelectors.currentSettings(s).defaultAgent?.config,
);
const updateDefaultModel = useUserStore((s) => s.updateDefaultModel);
const sendMessage = useConversationStore((s) => s.sendMessage);
const modelConfig = isDev ? defaultAgentConfig : ONBOARDING_PRODUCTION_DEFAULT_MODEL;
if (question.metadata?.recommendedSurface !== 'modelPicker') {
return (
<Flexbox gap={8}>
<QuestionHeader description={question.description} prompt={question.prompt} />
</Flexbox>
);
}
const handleContinue = async () => {
if (!isDev) {
await updateDefaultModel(
ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
);
}
await sendMessage({
message:
modelConfig?.model && modelConfig.provider
? `I am done with advanced setup. Keep my default model as ${modelConfig.provider}/${modelConfig.model}.`
: 'I am done with advanced setup.',
});
onDismissNode?.(question.node);
};
return (
<Flexbox gap={16}>
<QuestionHeader description={question.description} prompt={question.prompt} />
{isDev ? (
<ModelSelect
showAbility={false}
size={'large'}
style={{ width: '100%' }}
value={defaultAgentConfig}
onChange={({ model, provider }) => {
updateDefaultModel(model, provider);
}}
/>
) : (
<Text type={'secondary'}>
{t('proSettings.model.fixed', {
model: ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
provider: ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
})}
</Text>
)}
{enableKlavis && <KlavisServerList />}
<Button disabled={loading} type={'primary'} onClick={() => void handleContinue()}>
{t('next')}
</Button>
</Flexbox>
);
});
QuestionInfo.displayName = 'QuestionInfo';
const QuestionComposerPrefill = memo<{ question: UserAgentOnboardingQuestion }>(({ question }) => (
<Flexbox gap={8}>
<QuestionHeader description={question.description} prompt={question.prompt} />
</Flexbox>
));
QuestionComposerPrefill.displayName = 'QuestionComposerPrefill';
const QuestionRenderer = memo<QuestionRendererProps>(({ currentQuestion, onDismissNode }) => {
const loading = useConversationStore(messageStateSelectors.isInputLoading);
const sendMessage = useConversationStore((s) => s.sendMessage);
const handleChoice = async (choice: UserAgentOnboardingQuestionChoice) => {
const message = getChoiceMessage(choice);
if (message) {
await sendMessage({ message });
onDismissNode?.(currentQuestion.node);
}
};
return (
<Flexbox gap={16}>
{currentQuestion.mode === 'button_group' && (
<QuestionChoices loading={loading} question={currentQuestion} onChoose={handleChoice} />
)}
{currentQuestion.mode === 'form' && (
<QuestionForm loading={loading} question={currentQuestion} onDismissNode={onDismissNode} />
)}
{currentQuestion.mode === 'select' && (
<QuestionSelect
loading={loading}
question={currentQuestion}
onDismissNode={onDismissNode}
/>
)}
{currentQuestion.mode === 'info' && (
<QuestionInfo loading={loading} question={currentQuestion} onDismissNode={onDismissNode} />
)}
{currentQuestion.mode === 'composer_prefill' && (
<QuestionComposerPrefill question={currentQuestion} />
)}
</Flexbox>
);
});
QuestionRenderer.displayName = 'QuestionRenderer';
export default QuestionRenderer;

View File

@@ -12,7 +12,7 @@ describe('resolveAgentOnboardingContext', () => {
version: 1,
},
context: {
interactionHints: [],
currentQuestion: undefined,
},
topicId: 'topic-bootstrap',
},
@@ -24,7 +24,7 @@ describe('resolveAgentOnboardingContext', () => {
});
expect(result).toEqual({
interactionHints: [],
currentQuestion: undefined,
topicId: 'topic-store',
});
});
@@ -38,7 +38,7 @@ describe('resolveAgentOnboardingContext', () => {
version: 1,
},
context: {
interactionHints: [],
currentQuestion: undefined,
},
topicId: 'topic-bootstrap',
},
@@ -49,7 +49,7 @@ describe('resolveAgentOnboardingContext', () => {
});
expect(result).toEqual({
interactionHints: [],
currentQuestion: undefined,
topicId: 'topic-bootstrap',
});
});

View File

@@ -1,9 +1,9 @@
import type { UserAgentOnboarding, UserAgentOnboardingInteractionHint } from '@/types/user';
import type { UserAgentOnboarding, UserAgentOnboardingQuestion } from '@/types/user';
export interface AgentOnboardingBootstrapContext {
agentOnboarding: UserAgentOnboarding;
context: {
interactionHints: UserAgentOnboardingInteractionHint[];
currentQuestion?: UserAgentOnboardingQuestion;
};
topicId: string;
}
@@ -17,6 +17,6 @@ export const resolveAgentOnboardingContext = ({
bootstrapContext,
storedAgentOnboarding,
}: ResolveAgentOnboardingContextParams) => ({
interactionHints: bootstrapContext?.context.interactionHints ?? [],
currentQuestion: bootstrapContext?.context.currentQuestion,
topicId: storedAgentOnboarding?.activeTopicId ?? bootstrapContext?.topicId,
});

View File

@@ -36,7 +36,7 @@ const AgentOnboardingPage = memo(() => {
const { data, error, isLoading, mutate } = useOnlyFetchOnceSWR(
'agent-onboarding-bootstrap',
() => userService.getOrCreateAgentOnboardingContext(),
() => userService.getOrCreateOnboardingState(),
{
onSuccess: async () => {
await refreshUserState();
@@ -72,7 +72,7 @@ const AgentOnboardingPage = memo(() => {
}
const syncOnboardingContext = async () => {
const nextContext = await userService.getOrCreateAgentOnboardingContext();
const nextContext = await userService.getOrCreateOnboardingState();
await mutate(nextContext, { revalidate: false });
return nextContext;
@@ -89,14 +89,6 @@ const AgentOnboardingPage = memo(() => {
}
};
const handleSubmitInteractionUpdates = async (
updates: Parameters<typeof userService.proposeAgentOnboardingPatch>[0]['updates'],
) => {
await userService.proposeAgentOnboardingPatch({ updates });
await syncOnboardingContext();
await refreshUserState();
};
return (
<OnboardingContainer>
<Flexbox
@@ -115,10 +107,7 @@ const AgentOnboardingPage = memo(() => {
}}
>
<ErrorBoundary FallbackComponent={() => null}>
<AgentOnboardingConversation
interactionHints={currentContext.interactionHints}
onSubmitInteractionUpdates={handleSubmitInteractionUpdates}
/>
<AgentOnboardingConversation currentQuestion={currentContext.currentQuestion} />
</ErrorBoundary>
</OnboardingConversationProvider>
</Flexbox>

View File

@@ -228,11 +228,12 @@ export default {
'builtins.lobe-web-browsing.apiName.search': 'Search pages',
'builtins.lobe-web-browsing.inspector.noResults': 'No results',
'builtins.lobe-web-browsing.title': 'Web Search',
'builtins.lobe-web-onboarding.apiName.commitOnboardingNode': 'Commit onboarding step',
'builtins.lobe-web-onboarding.apiName.finishAgentOnboarding': 'Finish onboarding',
'builtins.lobe-web-onboarding.apiName.getOnboardingContext': 'Read onboarding context',
'builtins.lobe-web-onboarding.apiName.proposeOnboardingPatch': 'Update onboarding state',
'builtins.lobe-web-onboarding.apiName.redirectOfftopic': 'Redirect to onboarding',
'builtins.lobe-web-onboarding.apiName.askUserQuestion': 'Ask user question',
'builtins.lobe-web-onboarding.apiName.completeCurrentStep': 'Complete current step',
'builtins.lobe-web-onboarding.apiName.finishOnboarding': 'Finish onboarding',
'builtins.lobe-web-onboarding.apiName.getOnboardingState': 'Read onboarding state',
'builtins.lobe-web-onboarding.apiName.returnToOnboarding': 'Return to onboarding',
'builtins.lobe-web-onboarding.apiName.saveAnswer': 'Save user answer',
'builtins.lobe-web-onboarding.title': 'User Onboarding',
'builtins.lobe-topic-reference.apiName.getTopicContext': 'Get Topic Context',
'builtins.lobe-topic-reference.title': 'Topic Reference',

View File

@@ -6,8 +6,8 @@ import {
} from '@lobechat/types';
import {
Plans,
UserAgentOnboardingInteractionHintDraftSchema,
UserAgentOnboardingNodeSchema,
UserAgentOnboardingQuestionDraftSchema,
UserAgentOnboardingSchema,
UserAgentOnboardingUpdateSchema,
UserGuideSchema,
@@ -195,19 +195,19 @@ export const userRouter = router({
return ctx.userModel.updateUser({ interests: input });
}),
getOrCreateAgentOnboardingContext: userProcedure.query(async ({ ctx }) => {
getOrCreateOnboardingState: userProcedure.query(async ({ ctx }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.getOrCreateContext();
return onboardingService.getOrCreateState();
}),
getAgentOnboardingContext: userProcedure.query(async ({ ctx }) => {
getOnboardingState: userProcedure.query(async ({ ctx }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.getContext();
return onboardingService.getState();
}),
proposeAgentOnboardingPatch: userProcedure
saveOnboardingAnswer: userProcedure
.input(
z.object({
updates: z.array(UserAgentOnboardingUpdateSchema).min(1),
@@ -216,23 +216,23 @@ export const userRouter = router({
.mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.proposePatch(input);
return onboardingService.saveAnswer(input);
}),
proposeAgentOnboardingInteractions: userProcedure
askOnboardingQuestion: userProcedure
.input(
z.object({
hints: z.array(UserAgentOnboardingInteractionHintDraftSchema),
node: UserAgentOnboardingNodeSchema,
question: UserAgentOnboardingQuestionDraftSchema,
}),
)
.mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.proposeInteractions(input);
return onboardingService.askQuestion(input);
}),
commitAgentOnboardingNode: userProcedure
completeOnboardingStep: userProcedure
.input(
z.object({
node: UserAgentOnboardingNodeSchema,
@@ -241,10 +241,10 @@ export const userRouter = router({
.mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.commitNode(input.node);
return onboardingService.completeCurrentStep(input.node);
}),
redirectAgentOnboardingOfftopic: userProcedure
returnToOnboarding: userProcedure
.input(
z.object({
reason: z.string().optional(),
@@ -253,13 +253,13 @@ export const userRouter = router({
.mutation(async ({ ctx, input }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.redirectOfftopic(input.reason);
return onboardingService.returnToOnboarding(input.reason);
}),
finishAgentOnboarding: userProcedure.mutation(async ({ ctx }) => {
finishOnboarding: userProcedure.mutation(async ({ ctx }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
return onboardingService.finish();
return onboardingService.finishOnboarding();
}),
resetAgentOnboarding: userProcedure.mutation(async ({ ctx }) => {

View File

@@ -97,7 +97,7 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const context = await service.getContext();
const context = await service.getState();
expect(context.activeNode).toBe('agentIdentity');
expect(context.completedNodes).toEqual([]);
@@ -112,7 +112,7 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const result = await service.proposePatch({
const result = await service.saveAnswer({
updates: [
{
node: 'agentIdentity',
@@ -138,7 +138,7 @@ describe('OnboardingService', () => {
expect(persistedUserState.agentOnboarding.completedNodes).toEqual(['agentIdentity']);
});
it('preserves malformed flat patch fields so the service can return a structured failure', () => {
it('preserves node-scoped flat patch fields in the update schema', () => {
const parsed = UserAgentOnboardingUpdateSchema.parse({
node: 'agentIdentity',
patch: {
@@ -157,7 +157,7 @@ describe('OnboardingService', () => {
});
});
it('returns a structured invalid patch shape error for flat agent identity payloads', async () => {
it('accepts flat agent identity payloads scoped by node', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
@@ -165,7 +165,7 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const result = await service.proposePatch({
const result = await service.saveAnswer({
updates: [
{
node: 'agentIdentity',
@@ -179,22 +179,45 @@ describe('OnboardingService', () => {
],
});
expect(result.success).toBe(false);
expect(result.error).toEqual({
code: 'INVALID_PATCH_SHAPE',
expectedPatchPath: 'patch.agentIdentity',
message:
'Invalid patch shape for "agentIdentity". Put these fields under patch.agentIdentity instead of sending them at the top level of patch.',
receivedPatch: {
emoji: '🐍',
name: '小齐',
nature: 'Sharp, playful AI sidekick with insights',
vibe: 'Serpent-like: sharp, witty, and insightful',
},
receivedPatchKeys: ['emoji', 'name', 'nature', 'vibe'],
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({
updates: [
{
node: 'agentIdentity',
patch: {
vibe: '活泼',
},
},
],
});
expect(result.success).toBe(true);
expect(result.activeNode).toBe('agentIdentity');
expect(result.activeNodeDraftState).toEqual({
missingFields: ['emoji', 'name', 'nature'],
status: 'partial',
});
expect(result.content).toContain('Saved a partial draft');
expect(persistedUserState.agentOnboarding.draft.agentIdentity).toEqual({
vibe: '活泼',
});
expect(result.content).toContain('patch.agentIdentity');
expect(persistedUserState.agentOnboarding.agentIdentity).toBeUndefined();
expect(persistedUserState.agentOnboarding.completedNodes).toEqual([]);
});
@@ -206,7 +229,7 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const result = await service.proposePatch({
const result = await service.saveAnswer({
updates: [
{
node: 'userIdentity',
@@ -228,7 +251,7 @@ describe('OnboardingService', () => {
expect(result.activeNode).toBe('agentIdentity');
expect(result.requestedNode).toBe('userIdentity');
expect(result.instruction).toContain('Do not call userIdentity yet');
expect(result.interactionHints?.[0]?.node).toBe('agentIdentity');
expect(result.currentQuestion).toBeUndefined();
expect(result.mismatch).toBe(true);
expect(result.savedDraftFields).toEqual(['userIdentity']);
expect(persistedUserState.agentOnboarding.draft.userIdentity).toEqual({
@@ -239,7 +262,7 @@ describe('OnboardingService', () => {
});
});
it('builds interaction hints from onboarding state instead of the legacy currentNode field', async () => {
it('does not derive a current question from onboarding state without a stored question surface', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
currentNode: 'agentIdentity',
@@ -250,16 +273,13 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const context = await service.getContext();
const context = await service.getState();
expect(context.activeNode).toBe('agentIdentity');
expect(context.interactionHints?.map((hint) => hint.node)).toEqual([
'agentIdentity',
'agentIdentity',
]);
expect(context.currentQuestion).toBeUndefined();
});
it('derives later structured hints from completed nodes without reading legacy currentNode', async () => {
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',
@@ -270,14 +290,13 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const context = await service.getContext();
const context = await service.getState();
expect(context.activeNode).toBe('responseLanguage');
expect(context.interactionHints?.map((hint) => hint.node)).toEqual(['responseLanguage']);
expect(context.interactionHints?.[0]?.fields?.[0]?.value).toBe('zh-CN');
expect(context.currentQuestion).toBeUndefined();
});
it('marks weak fallback interaction hints as needing refresh', async () => {
it('returns no current question when the active node has no stored question surface', async () => {
persistedUserState.agentOnboarding = {
completedNodes: ['agentIdentity'],
draft: {},
@@ -285,15 +304,10 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const context = await service.getContext();
const context = await service.getState();
expect(context.activeNode).toBe('userIdentity');
expect(context.interactionHints?.[0]?.kind).toBe('composer_prefill');
expect(context.interactionPolicy).toEqual({
needsRefresh: true,
reason:
'Current node "userIdentity" only has weak fallback interaction hints. Generate a better interaction surface before your next visible reply.',
});
expect(context.currentQuestion).toBeUndefined();
});
it('supports batch updates across consecutive onboarding nodes', async () => {
@@ -304,7 +318,7 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const result = await service.proposePatch({
const result = await service.saveAnswer({
updates: [
{
node: 'agentIdentity',
@@ -334,11 +348,12 @@ 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');
});
it('stores AI-generated interaction hints for the active onboarding node', async () => {
it('stores the current question for the active onboarding node', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
@@ -346,70 +361,64 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const result = await service.proposeInteractions({
hints: [
{
actions: [
{
id: 'identity-default',
label: 'Use a warm default',
payload: {
kind: 'message',
message: 'Call yourself Xiao Qi, warm and curious, emoji 🐦.',
},
},
],
description: 'A tighter preset generated by the model.',
id: 'agent-identity-ai-preset',
kind: 'button_group',
submitMode: 'message',
title: 'AI-generated preset',
},
],
const result = await service.askQuestion({
node: 'agentIdentity',
question: {
choices: [
{
id: 'identity-default',
label: 'Use a warm default',
payload: {
kind: 'message',
message: 'Call yourself Xiao Qi, warm and curious, emoji 🐦.',
},
},
],
description: 'A tighter preset generated by the model.',
id: 'agent-identity-ai-preset',
mode: 'button_group',
prompt: 'AI-generated preset',
},
});
expect(result.success).toBe(true);
expect(result.storedHintIds).toEqual(['agent-identity-ai-preset']);
expect(result.interactionHints).toEqual([
expect(result.storedQuestionId).toBe('agent-identity-ai-preset');
expect(result.currentQuestion).toEqual(
expect.objectContaining({
id: 'agent-identity-ai-preset',
kind: 'button_group',
mode: 'button_group',
node: 'agentIdentity',
}),
]);
expect(persistedUserState.agentOnboarding.interactionSurface).toEqual({
hints: [
expect.objectContaining({
id: 'agent-identity-ai-preset',
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 AI-generated interaction hints after the onboarding node advances', async () => {
it('clears the current question after the onboarding node advances', async () => {
persistedUserState.agentOnboarding = {
completedNodes: [],
draft: {},
interactionSurface: {
hints: [
{
id: 'agent-identity-ai-preset',
kind: 'button_group',
node: 'agentIdentity',
},
],
questionSurface: {
node: 'agentIdentity',
question: {
id: 'agent-identity-ai-preset',
mode: 'button_group',
node: 'agentIdentity',
prompt: 'Preset',
},
updatedAt: '2026-03-24T00:00:00.000Z',
},
version: CURRENT_ONBOARDING_VERSION,
};
const service = new OnboardingService(mockDb, userId);
await service.proposePatch({
await service.saveAnswer({
updates: [
{
node: 'agentIdentity',
@@ -425,11 +434,11 @@ describe('OnboardingService', () => {
],
});
const context = await service.getContext();
const context = await service.getState();
expect(context.activeNode).toBe('userIdentity');
expect(persistedUserState.agentOnboarding.interactionSurface).toBeUndefined();
expect(context.interactionHints?.[0]?.node).toBe('userIdentity');
expect(persistedUserState.agentOnboarding.questionSurface).toBeUndefined();
expect(context.currentQuestion).toBeUndefined();
});
it('surfaces a committed profile across the new onboarding dimensions', async () => {
@@ -483,10 +492,10 @@ describe('OnboardingService', () => {
persistedUserState.interests = ['product strategy', 'data platforms'];
const service = new OnboardingService(mockDb, userId);
const context = await service.getContext();
const context = await service.getState();
expect(context.committed.agentIdentity?.name).toBe('小七');
expect(context.interactionHints?.[0]?.kind).toBe('select');
expect(context.currentQuestion).toBeUndefined();
expect(context.committed.profile?.identity?.professionalRole).toBe('Product Manager');
expect(context.committed.profile?.workStyle?.decisionMaking).toBe('data-informed but fast');
expect(context.committed.profile?.workContext?.tools).toEqual(['Notion', 'Figma', 'SQL']);
@@ -510,7 +519,7 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const result = await service.finish();
const result = await service.finishOnboarding();
expect(result.success).toBe(true);
expect(persistedUserState.agentOnboarding.finishedAt).toBeTruthy();
@@ -525,7 +534,7 @@ describe('OnboardingService', () => {
};
const service = new OnboardingService(mockDb, userId);
const result = await service.getOrCreateContext();
const result = await service.getOrCreateState();
expect(result.topicId).toBe('topic-1');
expect(mockTopicModel.create).toHaveBeenCalledWith({

File diff suppressed because it is too large Load Diff

View File

@@ -20,33 +20,33 @@ export const webOnboardingRuntime: ServerRuntimeRegistration = {
for (const api of WebOnboardingManifest.api) {
proxy[api.name] = async (args: Record<string, unknown>) => {
switch (api.name) {
case 'getOnboardingContext': {
const result = await service.getContext();
case 'getOnboardingState': {
const result = await service.getState();
return { content: JSON.stringify(result, null, 2), state: result, success: true };
}
case 'proposeOnboardingPatch': {
const result = await service.proposePatch(args as any);
case 'saveAnswer': {
const result = await service.saveAnswer(args as any);
return createWebOnboardingToolResult(result);
}
case 'proposeOnboardingInteractions': {
const result = await service.proposeInteractions(args as any);
case 'askUserQuestion': {
const result = await service.askQuestion(args as any);
return createWebOnboardingToolResult(result);
}
case 'commitOnboardingNode': {
const result = await service.commitNode(args.node as any);
case 'completeCurrentStep': {
const result = await service.completeCurrentStep(args.node as any);
return createWebOnboardingToolResult(result);
}
case 'redirectOfftopic': {
const result = await service.redirectOfftopic(args.reason as string | undefined);
case 'returnToOnboarding': {
const result = await service.returnToOnboarding(args.reason as string | undefined);
return createWebOnboardingToolResult(result);
}
case 'finishAgentOnboarding': {
const result = await service.finish();
case 'finishOnboarding': {
const result = await service.finishOnboarding();
return createWebOnboardingToolResult(result);
}

View File

@@ -5,9 +5,9 @@ import {
type SSOProvider,
type UserAgentOnboarding,
type UserAgentOnboardingDraft,
type UserAgentOnboardingInteractionHint,
type UserAgentOnboardingInteractionHintDraft,
type UserAgentOnboardingNode,
type UserAgentOnboardingQuestion,
type UserAgentOnboardingQuestionDraft,
type UserAgentOnboardingUpdate,
type UserGuide,
type UserInitializationState,
@@ -33,55 +33,55 @@ export class UserService {
return lambdaClient.user.getUserSSOProviders.query();
};
getOrCreateAgentOnboardingContext = async (): Promise<{
getOrCreateOnboardingState = async (): Promise<{
agentId: string;
agentOnboarding: UserAgentOnboarding;
context: {
activeNode?: UserAgentOnboardingNode;
activeNodeDraftState?: {
missingFields?: string[];
status: 'complete' | 'empty' | 'partial';
};
committed: Record<string, unknown>;
completedNodes: UserAgentOnboardingNode[];
currentQuestion?: UserAgentOnboardingQuestion;
draft: UserAgentOnboardingDraft;
finishedAt?: string;
interactionHints: UserAgentOnboardingInteractionHint[];
interactionPolicy: {
needsRefresh: boolean;
reason?: string;
};
topicId?: string;
version: number;
};
topicId: string;
}> => {
return lambdaClient.user.getOrCreateAgentOnboardingContext.query();
return lambdaClient.user.getOrCreateOnboardingState.query();
};
getAgentOnboardingContext = async () => {
return lambdaClient.user.getAgentOnboardingContext.query();
getOnboardingState = async () => {
return lambdaClient.user.getOnboardingState.query();
};
proposeAgentOnboardingPatch = async (params: { updates: UserAgentOnboardingUpdate[] }) => {
return lambdaClient.user.proposeAgentOnboardingPatch.mutate(
params as Parameters<typeof lambdaClient.user.proposeAgentOnboardingPatch.mutate>[0],
saveOnboardingAnswer = async (params: { updates: UserAgentOnboardingUpdate[] }) => {
return lambdaClient.user.saveOnboardingAnswer.mutate(
params as Parameters<typeof lambdaClient.user.saveOnboardingAnswer.mutate>[0],
);
};
proposeAgentOnboardingInteractions = async (params: {
hints: UserAgentOnboardingInteractionHintDraft[];
askOnboardingQuestion = async (params: {
node: UserAgentOnboardingNode;
question: UserAgentOnboardingQuestionDraft;
}) => {
return lambdaClient.user.proposeAgentOnboardingInteractions.mutate(params);
return lambdaClient.user.askOnboardingQuestion.mutate(params);
};
commitAgentOnboardingNode = async (node: UserAgentOnboardingNode) => {
return lambdaClient.user.commitAgentOnboardingNode.mutate({ node });
completeOnboardingStep = async (node: UserAgentOnboardingNode) => {
return lambdaClient.user.completeOnboardingStep.mutate({ node });
};
redirectAgentOnboardingOfftopic = async (reason?: string) => {
return lambdaClient.user.redirectAgentOnboardingOfftopic.mutate({ reason });
returnToOnboarding = async (reason?: string) => {
return lambdaClient.user.returnToOnboarding.mutate({ reason });
};
finishAgentOnboarding = async () => {
return lambdaClient.user.finishAgentOnboarding.mutate();
finishOnboarding = async () => {
return lambdaClient.user.finishOnboarding.mutate();
};
makeUserOnboarded = async () => {

View File

@@ -8,22 +8,18 @@ import { getApiNamesForIdentifier, hasExecutor } from './index';
describe('builtin executor registry', () => {
it('registers web onboarding executor APIs', () => {
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.getOnboardingContext)).toBe(
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.getOnboardingState)).toBe(
true,
);
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.proposeOnboardingPatch)).toBe(
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.saveAnswer)).toBe(true);
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.askUserQuestion)).toBe(true);
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.completeCurrentStep)).toBe(
true,
);
expect(
hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.proposeOnboardingInteractions),
).toBe(true);
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.commitOnboardingNode)).toBe(
true,
);
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.redirectOfftopic)).toBe(true);
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.finishAgentOnboarding)).toBe(
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.returnToOnboarding)).toBe(
true,
);
expect(hasExecutor(WebOnboardingIdentifier, WebOnboardingApiName.finishOnboarding)).toBe(true);
expect(getApiNamesForIdentifier(WebOnboardingIdentifier)).toEqual(
Object.values(WebOnboardingApiName),
);

View File

@@ -13,8 +13,8 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
readonly identifier = WebOnboardingIdentifier;
protected readonly apiEnum = WebOnboardingApiName;
getOnboardingContext = async (): Promise<BuiltinToolResult> => {
const result = await userService.getAgentOnboardingContext();
getOnboardingState = async (): Promise<BuiltinToolResult> => {
const result = await userService.getOnboardingState();
return {
content: JSON.stringify(result, null, 2),
@@ -23,25 +23,25 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
};
};
proposeOnboardingPatch = async (
saveAnswer = async (
params: {
updates: Parameters<typeof userService.proposeAgentOnboardingPatch>[0]['updates'];
updates: Parameters<typeof userService.saveOnboardingAnswer>[0]['updates'];
},
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.proposeAgentOnboardingPatch(params);
const result = await userService.saveOnboardingAnswer(params);
return createWebOnboardingToolResult(result);
};
proposeOnboardingInteractions = async (
askUserQuestion = async (
params: {
hints: Parameters<typeof userService.proposeAgentOnboardingInteractions>[0]['hints'];
node: Parameters<typeof userService.proposeAgentOnboardingInteractions>[0]['node'];
node: Parameters<typeof userService.askOnboardingQuestion>[0]['node'];
question: Parameters<typeof userService.askOnboardingQuestion>[0]['question'];
},
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.proposeAgentOnboardingInteractions(params);
const result = await userService.askOnboardingQuestion(params);
return {
content: result.content,
@@ -50,31 +50,31 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
};
};
commitOnboardingNode = async (
completeCurrentStep = async (
params: {
node: Parameters<typeof userService.commitAgentOnboardingNode>[0];
node: Parameters<typeof userService.completeOnboardingStep>[0];
},
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.commitAgentOnboardingNode(params.node);
const result = await userService.completeOnboardingStep(params.node);
return createWebOnboardingToolResult(result);
};
redirectOfftopic = async (
returnToOnboarding = async (
params: { reason?: string },
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.redirectAgentOnboardingOfftopic(params.reason);
const result = await userService.returnToOnboarding(params.reason);
return createWebOnboardingToolResult(result);
};
finishAgentOnboarding = async (
finishOnboarding = async (
_params: Record<string, never>,
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const result = await userService.finishAgentOnboarding();
const result = await userService.finishOnboarding();
return createWebOnboardingToolResult(result);
};

View File

@@ -44,4 +44,14 @@ describe('createWebOnboardingToolResult', () => {
});
expect(JSON.parse(result.content!)).toEqual(result.state);
});
it('serializes successful onboarding actions as structured payload only', () => {
const result = createWebOnboardingToolResult({
content: 'Committed step "agentIdentity". Continue with "userIdentity".',
success: true,
});
expect(result.content).toBe(JSON.stringify(result.state, null, 2));
expect(result.content).not.toContain('toolDirective');
});
});

View File

@@ -25,9 +25,10 @@ export const createWebOnboardingToolResult = <T extends WebOnboardingToolActionR
isError,
success: result.success,
};
const content = JSON.stringify(payload, null, 2);
return {
content: JSON.stringify(payload, null, 2),
content,
...(errorMessage ? { error: { body: result, message: errorMessage, type: errorType } } : {}),
state: payload,
success: result.success,