mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ 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:
@@ -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": "调用参数",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 node’s 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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
290
src/features/Onboarding/Agent/QuestionRenderer.test.tsx
Normal file
290
src/features/Onboarding/Agent/QuestionRenderer.test.tsx
Normal 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'),
|
||||
});
|
||||
});
|
||||
});
|
||||
424
src/features/Onboarding/Agent/QuestionRenderer.tsx
Normal file
424
src/features/Onboarding/Agent/QuestionRenderer.tsx
Normal 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;
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user