mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ feat(onboarding): enhance agent onboarding with new question renderer and refined interaction logic
- Introduced a new `QuestionRendererView` component to streamline the rendering of onboarding questions. - Refactored the `QuestionRenderer` to utilize a runtime hook for improved state management and separation of concerns. - Updated the onboarding context to fallback to stored questions when the current question is empty, enhancing user experience. - Simplified the onboarding API by removing unnecessary read token requirements from various endpoints. - Added tests to validate the new question rendering logic and ensure proper functionality. This update aims to create a more efficient and user-friendly onboarding experience by improving the question handling and rendering process. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -199,6 +199,7 @@
|
||||
"@langchain/community": "^0.3.59",
|
||||
"@lexical/utils": "^0.39.0",
|
||||
"@lobechat/agent-runtime": "workspace:*",
|
||||
"@lobechat/builtin-agent-onboarding": "workspace:*",
|
||||
"@lobechat/builtin-agents": "workspace:*",
|
||||
"@lobechat/builtin-skills": "workspace:*",
|
||||
"@lobechat/builtin-tool-agent-builder": "workspace:*",
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
} from '@lobechat/types';
|
||||
import { Button, Flexbox, Input, Select, Text } from '@lobehub/ui';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import type { type ChangeEvent, memo, ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type FormValue = string | string[];
|
||||
|
||||
@@ -248,6 +249,7 @@ const QuestionForm = memo<{
|
||||
if (!message) return;
|
||||
|
||||
await onSendMessage(message);
|
||||
|
||||
onDismissNode?.(question.node);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,177 +1,55 @@
|
||||
const systemRoleTemplate = `
|
||||
You are the dedicated web onboarding agent.
|
||||
|
||||
This is not open-ended chatting. This is a guided first conversation with a strict job:
|
||||
- complete onboarding
|
||||
- move one node at a time
|
||||
- keep the conversation natural
|
||||
- leave the user with a sharp, useful understanding of what you can do for them
|
||||
This is a guided first conversation with one job: complete onboarding and leave the user with a clear sense of how you can help.
|
||||
|
||||
Style:
|
||||
- Sound like a real person, not support copy.
|
||||
- Be concise. Usually 1 short paragraph. Sometimes 2.
|
||||
- Be concise.
|
||||
- Ask one focused question at a time.
|
||||
- Cut filler, praise, and generic enthusiasm.
|
||||
- Have some personality, but do not let personality slow the flow.
|
||||
- Keep the tone natural.
|
||||
- Avoid filler and generic enthusiasm.
|
||||
|
||||
Language:
|
||||
- The preferred reply language is mandatory.
|
||||
- Every visible reply must be entirely in that language unless the user explicitly switches languages.
|
||||
- Keep tool names and schema keys in English only inside tool calls.
|
||||
- Before sending a visible reply, check that the reply, suggested choices, and any visible labels all follow the required language.
|
||||
|
||||
Non-negotiable protocol:
|
||||
Operational rules:
|
||||
1. The first onboarding tool call of every turn must be getOnboardingState.
|
||||
2. getOnboardingState is the only source of truth for:
|
||||
- activeNode
|
||||
- activeNodeDraftState
|
||||
- committed values
|
||||
- completedNodes
|
||||
- control
|
||||
- draft
|
||||
- currentQuestion
|
||||
3. Only use these onboarding tools to move the flow:
|
||||
- getOnboardingState
|
||||
- askUserQuestion
|
||||
- saveAnswer
|
||||
- completeCurrentStep
|
||||
- returnToOnboarding
|
||||
- finishOnboarding
|
||||
4. Never say something was saved, committed, or finished unless the tool call succeeded.
|
||||
5. Never call a later node just because the user mentioned later information early. Finish the active node first.
|
||||
6. Never browse, research, search, use memory, or solve unrelated tasks during onboarding.
|
||||
7. Never expose internal tool names or node names unless it is genuinely necessary.
|
||||
2. activeNode is the only step you may act on.
|
||||
3. Use exactly one primary onboarding action for the active node:
|
||||
- saveAnswer when the user already gave a clear answer for the active node
|
||||
- askUserQuestion when the active node still needs an answer and currentQuestion is missing, weak, or stale
|
||||
- completeCurrentStep only when the existing draft for the active node is already complete and the user is only confirming it
|
||||
- returnToOnboarding when the user goes off-topic
|
||||
- finishOnboarding only from summary after the user confirms the summary
|
||||
4. If currentQuestion is missing or weak and the active node still needs an answer, call askUserQuestion before any visible reply.
|
||||
5. Never skip ahead to a later node.
|
||||
6. Never claim something was saved or completed unless the tool call succeeded.
|
||||
7. If a tool call fails, stay on the active node and recover from that result only.
|
||||
|
||||
Field contract:
|
||||
- Only send node-scoped fields that the service actually accepts.
|
||||
- Never invent keys such as alias, role, styleLabel, personalityTraits, or any other schema not listed below.
|
||||
- Required means the step cannot be committed until those fields exist in draft.
|
||||
- agentIdentity:
|
||||
- allowed: emoji, name, nature, vibe
|
||||
- required: emoji, name, nature, vibe
|
||||
- this node is about the assistant identity only, not the user
|
||||
- userIdentity:
|
||||
- allowed: name, professionalRole, domainExpertise, summary
|
||||
- required: summary
|
||||
- workStyle:
|
||||
- allowed: communicationStyle, decisionMaking, socialMode, thinkingPreferences, workStyle, summary
|
||||
- required: summary
|
||||
- workContext:
|
||||
- allowed: activeProjects, currentFocus, interests, thisQuarter, thisWeek, tools, summary
|
||||
- required: summary
|
||||
- painPoints:
|
||||
- allowed: blockedBy, frustrations, noTimeFor, summary
|
||||
- required: summary
|
||||
- responseLanguage:
|
||||
- allowed: responseLanguage
|
||||
- required: responseLanguage
|
||||
- proSettings:
|
||||
- allowed: model, provider
|
||||
- required for a full save: model, provider
|
||||
- summary:
|
||||
- do not use saveAnswer for summary
|
||||
- summarize committed data, then ask only whether the summary is accurate
|
||||
- after a light confirmation, call finishOnboarding immediately
|
||||
- do not ask what to do next until onboarding is already finished
|
||||
|
||||
Read-token contract:
|
||||
- Every onboarding tool call after getOnboardingState must include the latest control.readToken.
|
||||
- That token is single-use for the current state snapshot.
|
||||
- If you need another onboarding tool after saveAnswer, completeCurrentStep, returnToOnboarding, or a failed action, call getOnboardingState again first and use the new control.readToken.
|
||||
|
||||
Turn algorithm:
|
||||
1. Call getOnboardingState.
|
||||
2. Read activeNode, activeNodeDraftState, currentQuestion, draft, committed values, and control.
|
||||
3. Choose exactly one primary action for the active node:
|
||||
- askUserQuestion:
|
||||
Use this when currentQuestion is missing, weak, stale, mismatched to the active node, or not the best way to get the answer.
|
||||
- saveAnswer:
|
||||
Use this when the user has already given a clear answer for the active node.
|
||||
Batch multiple consecutive nodes only when the user clearly answered them in one turn.
|
||||
- completeCurrentStep:
|
||||
Use this only when activeNodeDraftState.status is complete and the user is clearly confirming the existing draft.
|
||||
- returnToOnboarding:
|
||||
Use this when the user goes off-topic and you need to pull the conversation back.
|
||||
- finishOnboarding:
|
||||
Use this only after the summary is shown and the user clearly confirms it.
|
||||
4. After the tool result, send the smallest useful visible reply.
|
||||
5. If a tool result gives you a directive, follow it literally.
|
||||
6. If any onboarding tool returns success: false:
|
||||
- do not pretend progress happened
|
||||
- do not move to another node
|
||||
- use the error content, instruction, activeNode, and activeNodeDraftState as recovery signals
|
||||
- if the failure happened after saveAnswer, completeCurrentStep, returnToOnboarding, or finishOnboarding, read onboarding state again before the next visible reply
|
||||
- then ask only for the missing information required to finish the current node
|
||||
7. A short confirmation such as "ok", "好的", or "继续" is not enough to complete a node unless the current draft is already complete.
|
||||
8. If a successful tool result says the state advanced and the next node has no currentQuestion, call getOnboardingState again and then askUserQuestion for the new active node before any visible reply.
|
||||
|
||||
How to use askUserQuestion:
|
||||
- Define one current question, not a questionnaire.
|
||||
- The question should help the user answer with minimal effort.
|
||||
- Prefer stronger answer surfaces when they reduce typing:
|
||||
- button_group for a few natural answer choices
|
||||
- form when several tightly related fields belong together
|
||||
- select for a single constrained choice
|
||||
- info only when the UI itself is doing most of the work
|
||||
- composer_prefill only as a weak temporary fallback
|
||||
- A button_group must contain executable choices. Do not create inert buttons.
|
||||
- Choice labels should look like things the user would naturally say.
|
||||
- Prefer payload.message for conversational choices and payload.patch only when direct structured submission is clearly better.
|
||||
|
||||
What counts as a weak question:
|
||||
- generic composer_prefill
|
||||
- info-only question with no clear action
|
||||
- vague text like "continue this step"
|
||||
- choices that are not actionable
|
||||
|
||||
Question strategy by node:
|
||||
- agentIdentity:
|
||||
- You just came online. You do not know your own name, nature, vibe, or emoji yet.
|
||||
- If the user gives weak guidance like "hi", "whatever", or "up to you", offer concrete options or make a coherent proposal yourself and ask for light confirmation.
|
||||
- Even if the user also introduces themselves, do not leave this node early.
|
||||
- Never ask for the user's name or profession in this node.
|
||||
- userIdentity:
|
||||
- Figure out who they are professionally.
|
||||
- Capture enough detail to avoid vague labels.
|
||||
- Prefer producing a concise summary plus any available structured name, professionalRole, or domainExpertise.
|
||||
- workStyle:
|
||||
- Figure out how they think, decide, communicate, and work with others.
|
||||
- The commit anchor is summary. Extra fields are optional support.
|
||||
- workContext:
|
||||
- Figure out what they are focused on now, what tools they use, and what domains they care about.
|
||||
- The commit anchor is summary. Extra fields are optional support.
|
||||
- painPoints:
|
||||
- Figure out where friction actually lives.
|
||||
- Look for bottlenecks, neglected work, repeated frustration, and unmet needs.
|
||||
- The commit anchor is summary. Extra fields are optional support.
|
||||
- responseLanguage:
|
||||
- Capture the default reply language clearly.
|
||||
- proSettings:
|
||||
- Capture a model/provider only if the user gives a real preference or is ready to continue.
|
||||
- Do not invent setup work.
|
||||
- summary:
|
||||
- Describe the user like you are telling a friend about someone interesting you just met.
|
||||
- Do not output a sterile checklist.
|
||||
- Then give 3-5 concrete ways you can help next.
|
||||
- End by asking only whether the summary is accurate.
|
||||
- If the user gives a light confirmation such as "ok", "可以", "确认", "就这样", or equivalent agreement, treat that as confirmation and finish onboarding.
|
||||
- Do not ask where to start inside onboarding. That belongs to the first post-onboarding turn.
|
||||
Questioning:
|
||||
- Ask only what is needed to finish the active node.
|
||||
- Prefer one actionable question over a questionnaire.
|
||||
- Keep visible choices natural and executable.
|
||||
|
||||
Boundaries:
|
||||
- Do not turn onboarding into open-ended interviewing, therapy, career coaching, or generic profile building.
|
||||
- Do not ask broad discovery questions unless they directly help finish the active node.
|
||||
- If the user goes off-topic, acknowledge briefly, call returnToOnboarding, and pull back to the active question.
|
||||
- Do not browse, research, or solve unrelated tasks during onboarding.
|
||||
- Do not expose internal node names unless necessary.
|
||||
|
||||
Completion:
|
||||
Summary:
|
||||
- Only the summary node can end onboarding.
|
||||
- Do not call finishOnboarding before the summary is shown and confirmed.
|
||||
- At summary, describe the user like a person, not a checklist.
|
||||
- Give 3-5 concrete ways you can help next.
|
||||
- Ask only whether the summary is accurate.
|
||||
- After a light confirmation, call finishOnboarding.
|
||||
`.trim();
|
||||
|
||||
export const createSystemRole = (userLocale?: string) =>
|
||||
[
|
||||
systemRoleTemplate,
|
||||
userLocale
|
||||
? `Preferred reply language: ${userLocale}. This is mandatory. Every visible reply, question, and visible choice label must be entirely in ${userLocale} unless the user explicitly asks to switch. If any sentence drifts into another language, rewrite it before sending.`
|
||||
? `Preferred reply language: ${userLocale}. This is mandatory. Every visible reply, question, and visible choice label must be entirely in ${userLocale} unless the user explicitly asks to switch.`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -1,76 +1,35 @@
|
||||
export const toolSystemPrompt = `
|
||||
You are the only toolset allowed to manage the web onboarding flow.
|
||||
You manage the web onboarding flow.
|
||||
|
||||
Protocol:
|
||||
Operational rules:
|
||||
1. The first onboarding tool call of every turn must be getOnboardingState.
|
||||
2. Treat getOnboardingState as the only source of truth for:
|
||||
- activeNode
|
||||
- activeNodeDraftState
|
||||
- committed values
|
||||
- completedNodes
|
||||
- control
|
||||
- draft
|
||||
- currentQuestion
|
||||
3. Only advance the flow through:
|
||||
2. Treat activeNode as the only step you may act on.
|
||||
3. Use only:
|
||||
- askUserQuestion
|
||||
- saveAnswer
|
||||
- completeCurrentStep
|
||||
- returnToOnboarding
|
||||
- finishOnboarding
|
||||
4. Prefer saveAnswer when the user already gave a clear answer for the active node.
|
||||
5. Use completeCurrentStep only when activeNodeDraftState.status is complete and the user is just confirming the existing draft.
|
||||
6. Never call a later node before the active step is complete, even if the user mentioned later-step information early.
|
||||
7. Never finish onboarding before the summary step.
|
||||
8. Do not claim onboarding data was saved unless the tool call succeeded.
|
||||
9. If any onboarding tool returns success: false, do not advance. Use the returned instruction, activeNode, and activeNodeDraftState to recover, then ask only for the missing information on the current node.
|
||||
10. Every onboarding tool call after getOnboardingState must include the latest control.readToken.
|
||||
11. If you need another onboarding tool after saveAnswer, completeCurrentStep, returnToOnboarding, or finishOnboarding, call getOnboardingState again first and use the new control.readToken.
|
||||
4. Use exactly one primary action for the active node:
|
||||
- saveAnswer when the user already answered the active node
|
||||
- askUserQuestion when the active node still needs an answer and currentQuestion is missing, weak, or stale
|
||||
- completeCurrentStep only for an already complete active-node draft
|
||||
- returnToOnboarding for off-topic turns
|
||||
- finishOnboarding only after the summary is shown and confirmed
|
||||
5. If currentQuestion is missing or weak and the active node still needs an answer, call askUserQuestion before any visible reply.
|
||||
6. saveAnswer accepts only fields for the active node.
|
||||
7. If saveAnswer makes the active node complete, it will commit and advance automatically.
|
||||
8. If a tool call fails, do not advance. Recover on the current node.
|
||||
9. Never finish onboarding before summary.
|
||||
|
||||
Accepted fields by node:
|
||||
- agentIdentity: emoji, name, nature, vibe
|
||||
- userIdentity: name, professionalRole, domainExpertise, summary
|
||||
- workStyle: communicationStyle, decisionMaking, socialMode, thinkingPreferences, workStyle, summary
|
||||
- workContext: activeProjects, currentFocus, interests, thisQuarter, thisWeek, tools, summary
|
||||
- painPoints: blockedBy, frustrations, noTimeFor, summary
|
||||
- responseLanguage: responseLanguage
|
||||
- proSettings: model, provider
|
||||
- Never invent fields outside this contract.
|
||||
Question surfaces:
|
||||
- Ask one focused question for the active node.
|
||||
- Keep choices actionable.
|
||||
- Prefer natural reply options.
|
||||
|
||||
askUserQuestion rules:
|
||||
1. askUserQuestion defines one current question for the active node.
|
||||
2. Prefer the strongest useful answer surface:
|
||||
- button_group
|
||||
- form
|
||||
- select
|
||||
- info
|
||||
- composer_prefill
|
||||
3. button_group choices must be executable.
|
||||
4. Prefer payload.message for natural conversational answers.
|
||||
5. Use payload.patch only when direct structured submission is clearly better.
|
||||
6. Avoid weak fallback questions when a better question can be generated now.
|
||||
|
||||
saveAnswer rules:
|
||||
1. saveAnswer accepts batch updates.
|
||||
2. Batch only when the user clearly answered multiple consecutive nodes in one turn.
|
||||
3. Keep updates in node order.
|
||||
4. saveAnswer patches must use only the accepted fields for that node.
|
||||
5. agentIdentity is about the assistant identity only. Do not ask for or save the user's name, role, or profile in agentIdentity.
|
||||
6. If saveAnswer or completeCurrentStep advances the state and the new active node has no currentQuestion, call getOnboardingState again and then askUserQuestion for that new node before replying.
|
||||
|
||||
Node order:
|
||||
- agentIdentity
|
||||
- userIdentity
|
||||
- workStyle
|
||||
- workContext
|
||||
- painPoints
|
||||
- responseLanguage
|
||||
- proSettings
|
||||
- summary
|
||||
|
||||
Summary rule:
|
||||
- At summary, describe the user like a person, not a report.
|
||||
- Then give 3-5 concrete service suggestions.
|
||||
- Then ask only whether the summary is accurate.
|
||||
- If the user gives a light confirmation, finish onboarding immediately.
|
||||
- Do not ask where to start until onboarding is already complete.
|
||||
Summary:
|
||||
- Summarize the user like a person.
|
||||
- Give 3-5 concrete ways you can help next.
|
||||
- Ask only whether the summary is accurate.
|
||||
- After a light confirmation, call finishOnboarding.
|
||||
`.trim();
|
||||
|
||||
@@ -7,7 +7,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
|
||||
api: [
|
||||
{
|
||||
description:
|
||||
'Read the current onboarding state, including the active step, committed values, saved draft, any currently stored question surface, and the control metadata required for the next onboarding tool call.',
|
||||
'Read the current onboarding state, including the active step, committed values, saved draft, any currently stored question surface, and lightweight control metadata.',
|
||||
name: WebOnboardingApiName.getOnboardingState,
|
||||
parameters: {
|
||||
properties: {},
|
||||
@@ -34,11 +34,6 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
|
||||
],
|
||||
type: 'string',
|
||||
},
|
||||
readToken: {
|
||||
description:
|
||||
'The latest single-use read token returned by getOnboardingState.control.readToken.',
|
||||
type: 'string',
|
||||
},
|
||||
question: {
|
||||
properties: {
|
||||
choices: {
|
||||
@@ -145,7 +140,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
required: ['node', 'question', 'readToken'],
|
||||
required: ['node', 'question'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
@@ -155,11 +150,6 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
|
||||
name: WebOnboardingApiName.saveAnswer,
|
||||
parameters: {
|
||||
properties: {
|
||||
readToken: {
|
||||
description:
|
||||
'The latest single-use read token returned by getOnboardingState.control.readToken.',
|
||||
type: 'string',
|
||||
},
|
||||
updates: {
|
||||
items: {
|
||||
properties: {
|
||||
@@ -187,7 +177,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
|
||||
type: 'array',
|
||||
},
|
||||
},
|
||||
required: ['readToken', 'updates'],
|
||||
required: ['updates'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
@@ -210,13 +200,8 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
|
||||
],
|
||||
type: 'string',
|
||||
},
|
||||
readToken: {
|
||||
description:
|
||||
'The latest single-use read token returned by getOnboardingState.control.readToken.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['node', 'readToken'],
|
||||
required: ['node'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
@@ -226,14 +211,8 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
|
||||
name: WebOnboardingApiName.returnToOnboarding,
|
||||
parameters: {
|
||||
properties: {
|
||||
readToken: {
|
||||
description:
|
||||
'The latest single-use read token returned by getOnboardingState.control.readToken.',
|
||||
type: 'string',
|
||||
},
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['readToken'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
@@ -242,14 +221,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
|
||||
'Finish the onboarding flow from the summary step and mirror the legacy onboarding completion flag.',
|
||||
name: WebOnboardingApiName.finishOnboarding,
|
||||
parameters: {
|
||||
properties: {
|
||||
readToken: {
|
||||
description:
|
||||
'The latest single-use read token returned by getOnboardingState.control.readToken.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['readToken'],
|
||||
properties: {},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -123,18 +123,11 @@ export interface UserAgentOnboardingQuestionSurface {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserAgentOnboardingExecutionGuard {
|
||||
issuedAt: string;
|
||||
readToken: string;
|
||||
}
|
||||
|
||||
export interface UserAgentOnboardingControl {
|
||||
allowedTools: string[];
|
||||
canCompleteCurrentStep: boolean;
|
||||
canFinish: boolean;
|
||||
missingFields: string[];
|
||||
readToken?: string;
|
||||
readTokenRequired: boolean;
|
||||
}
|
||||
|
||||
export interface UserAgentOnboardingContext {
|
||||
@@ -168,7 +161,6 @@ export interface UserAgentOnboarding {
|
||||
agentIdentity?: UserOnboardingAgentIdentity;
|
||||
completedNodes?: UserAgentOnboardingNode[];
|
||||
draft?: UserAgentOnboardingDraft;
|
||||
executionGuard?: UserAgentOnboardingExecutionGuard;
|
||||
finishedAt?: string;
|
||||
profile?: UserOnboardingProfile;
|
||||
questionSurface?: UserAgentOnboardingQuestionSurface;
|
||||
@@ -289,17 +281,11 @@ const UserAgentOnboardingQuestionSurfaceSchema = z.object({
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
const UserAgentOnboardingExecutionGuardSchema = z.object({
|
||||
issuedAt: z.string(),
|
||||
readToken: z.string(),
|
||||
});
|
||||
|
||||
export const UserAgentOnboardingSchema = z.object({
|
||||
activeTopicId: z.string().optional(),
|
||||
agentIdentity: UserOnboardingAgentIdentitySchema.optional(),
|
||||
completedNodes: z.array(UserAgentOnboardingNodeSchema).optional(),
|
||||
draft: UserAgentOnboardingDraftSchema.optional(),
|
||||
executionGuard: UserAgentOnboardingExecutionGuardSchema.optional(),
|
||||
finishedAt: z.string().optional(),
|
||||
profile: z
|
||||
.object({
|
||||
|
||||
127
src/features/Onboarding/Agent/QuestionRenderer.runtime.test.tsx
Normal file
127
src/features/Onboarding/Agent/QuestionRenderer.runtime.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import QuestionRenderer from './QuestionRenderer';
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/env', () => ({ isDev: false }));
|
||||
|
||||
vi.mock('@/const/onboarding', () => ({
|
||||
ONBOARDING_PRODUCTION_DEFAULT_MODEL: {
|
||||
model: 'gpt-4.1-mini',
|
||||
provider: 'openai',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/Conversation', () => ({
|
||||
useConversationStore: (selector: (state: any) => unknown) =>
|
||||
selector({
|
||||
sendMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/Conversation/store', () => ({
|
||||
messageStateSelectors: {
|
||||
isInputLoading: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/global', () => ({
|
||||
useGlobalStore: (selector: (state: any) => unknown) =>
|
||||
selector({
|
||||
switchLocale: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/serverConfig', () => ({
|
||||
serverConfigSelectors: {
|
||||
enableKlavis: () => false,
|
||||
},
|
||||
useServerConfigStore: (selector: (state: any) => unknown) => selector({}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/user', () => ({
|
||||
useUserStore: (selector: (state: any) => unknown) =>
|
||||
selector({
|
||||
updateGeneralConfig: vi.fn(),
|
||||
updateDefaultModel: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/user/selectors', () => ({
|
||||
settingsSelectors: {
|
||||
currentSettings: () => ({
|
||||
defaultAgent: {},
|
||||
general: {},
|
||||
}),
|
||||
},
|
||||
userGeneralSettingsSelectors: {
|
||||
currentResponseLanguage: () => 'en-US',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/EmojiPicker', () => ({
|
||||
default: () => <div data-testid="emoji-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ModelSelect', () => ({
|
||||
default: () => <div data-testid="model-select" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/routes/onboarding/components/KlavisServerList', () => ({
|
||||
default: () => <div data-testid="klavis-list" />,
|
||||
}));
|
||||
|
||||
describe('QuestionRenderer runtime', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('dismisses the inline question without waiting for the full send lifecycle', async () => {
|
||||
const onDismissNode = vi.fn();
|
||||
|
||||
sendMessage.mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Intentionally unresolved to simulate a long-running streaming lifecycle.
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<QuestionRenderer
|
||||
currentQuestion={{
|
||||
choices: [
|
||||
{
|
||||
id: 'preset',
|
||||
label: 'Warm + curious',
|
||||
payload: {
|
||||
kind: 'message',
|
||||
message: 'hello from hint',
|
||||
},
|
||||
style: 'primary',
|
||||
},
|
||||
],
|
||||
id: 'question-1',
|
||||
mode: 'button_group',
|
||||
node: 'agentIdentity',
|
||||
prompt: 'Pick one',
|
||||
}}
|
||||
onDismissNode={onDismissNode}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Warm + curious' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendMessage).toHaveBeenCalledWith({ message: 'hello from hint' });
|
||||
expect(onDismissNode).toHaveBeenCalledWith('agentIdentity');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,85 +1,20 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import QuestionRenderer from './QuestionRenderer';
|
||||
import QuestionRendererView from './QuestionRendererView';
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
const baseProps = {
|
||||
fallbackQuestionDescription: 'agent.telemetryHint',
|
||||
fallbackTextFieldLabel: 'agent.telemetryHint',
|
||||
fallbackTextFieldPlaceholder: 'agent.telemetryHint',
|
||||
nextLabel: 'next',
|
||||
onSendMessage: sendMessage,
|
||||
submitLabel: 'next',
|
||||
} as const;
|
||||
|
||||
vi.mock('@/utils/env', () => ({ isDev: false }));
|
||||
|
||||
vi.mock('@/const/onboarding', () => ({
|
||||
ONBOARDING_PRODUCTION_DEFAULT_MODEL: {
|
||||
model: 'gpt-4.1-mini',
|
||||
provider: 'openai',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/Conversation', () => ({
|
||||
useConversationStore: (selector: (state: any) => unknown) =>
|
||||
selector({
|
||||
sendMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/Conversation/store', () => ({
|
||||
messageStateSelectors: {
|
||||
isInputLoading: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/global', () => ({
|
||||
useGlobalStore: (selector: (state: any) => unknown) =>
|
||||
selector({
|
||||
switchLocale: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/serverConfig', () => ({
|
||||
serverConfigSelectors: {
|
||||
enableKlavis: () => false,
|
||||
},
|
||||
useServerConfigStore: (selector: (state: any) => unknown) => selector({}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/user', () => ({
|
||||
useUserStore: (selector: (state: any) => unknown) =>
|
||||
selector({
|
||||
updateGeneralConfig: vi.fn(),
|
||||
updateDefaultModel: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/user/selectors', () => ({
|
||||
settingsSelectors: {
|
||||
currentSettings: () => ({
|
||||
defaultAgent: {},
|
||||
general: {},
|
||||
}),
|
||||
},
|
||||
userGeneralSettingsSelectors: {
|
||||
currentResponseLanguage: () => 'en-US',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/EmojiPicker', () => ({
|
||||
default: () => <div data-testid="emoji-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ModelSelect', () => ({
|
||||
default: () => <div data-testid="model-select" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/routes/onboarding/components/KlavisServerList', () => ({
|
||||
default: () => <div data-testid="klavis-list" />,
|
||||
}));
|
||||
|
||||
describe('QuestionRenderer', () => {
|
||||
describe('QuestionRendererView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -88,7 +23,8 @@ describe('QuestionRenderer', () => {
|
||||
const onDismissNode = vi.fn();
|
||||
|
||||
render(
|
||||
<QuestionRenderer
|
||||
<QuestionRendererView
|
||||
{...baseProps}
|
||||
currentQuestion={{
|
||||
choices: [
|
||||
{
|
||||
@@ -112,7 +48,7 @@ describe('QuestionRenderer', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Warm + curious' }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({ message: 'hello from hint' });
|
||||
expect(sendMessage).toHaveBeenCalledWith('hello from hint');
|
||||
await waitFor(() => {
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onDismissNode).toHaveBeenCalledWith('agentIdentity');
|
||||
@@ -121,7 +57,8 @@ describe('QuestionRenderer', () => {
|
||||
|
||||
it('falls back to sending the action label when a message button has no payload', async () => {
|
||||
render(
|
||||
<QuestionRenderer
|
||||
<QuestionRendererView
|
||||
{...baseProps}
|
||||
currentQuestion={{
|
||||
choices: [
|
||||
{
|
||||
@@ -140,13 +77,14 @@ describe('QuestionRenderer', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI 产品开发者' }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({ message: 'AI 产品开发者' });
|
||||
expect(sendMessage).toHaveBeenCalledWith('AI 产品开发者');
|
||||
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('falls back to the action label for patch-style actions', async () => {
|
||||
render(
|
||||
<QuestionRenderer
|
||||
<QuestionRendererView
|
||||
{...baseProps}
|
||||
currentQuestion={{
|
||||
choices: [
|
||||
{
|
||||
@@ -171,14 +109,13 @@ describe('QuestionRenderer', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use Chinese' }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
message: 'Use Chinese',
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith('Use Chinese');
|
||||
});
|
||||
|
||||
it('falls back to a text form when a button group has no choices', async () => {
|
||||
render(
|
||||
<QuestionRenderer
|
||||
<QuestionRendererView
|
||||
{...baseProps}
|
||||
currentQuestion={{
|
||||
id: 'agent-identity-missing-choices',
|
||||
mode: 'button_group',
|
||||
@@ -193,16 +130,17 @@ describe('QuestionRenderer', () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'next' }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
message: ['Q: agent.telemetryHint', 'A: 叫 shishi,风格偏直接。'].join('\n'),
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
['Q: agent.telemetryHint', 'A: 叫 shishi,风格偏直接。'].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('formats form submissions as question-answer text', async () => {
|
||||
const onDismissNode = vi.fn();
|
||||
|
||||
render(
|
||||
<QuestionRenderer
|
||||
<QuestionRendererView
|
||||
{...baseProps}
|
||||
currentQuestion={{
|
||||
fields: [
|
||||
{
|
||||
@@ -237,9 +175,9 @@ describe('QuestionRenderer', () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'next' }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
message: ['Q: Role', 'A: Independent developer', '', 'Q: Name', 'A: Ada'].join('\n'),
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
['Q: Role', 'A: Independent developer', '', 'Q: Name', 'A: Ada'].join('\n'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(onDismissNode).toHaveBeenCalledWith('userIdentity');
|
||||
});
|
||||
@@ -247,7 +185,8 @@ describe('QuestionRenderer', () => {
|
||||
|
||||
it('submits the form when pressing Enter in a text input', async () => {
|
||||
render(
|
||||
<QuestionRenderer
|
||||
<QuestionRendererView
|
||||
{...baseProps}
|
||||
currentQuestion={{
|
||||
fields: [
|
||||
{
|
||||
@@ -274,14 +213,13 @@ describe('QuestionRenderer', () => {
|
||||
key: 'Enter',
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
message: ['Q: Role', 'A: Independent developer'].join('\n'),
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledWith(['Q: Role', 'A: Independent developer'].join('\n'));
|
||||
});
|
||||
|
||||
it('formats select submissions as question-answer text', async () => {
|
||||
render(
|
||||
<QuestionRenderer
|
||||
<QuestionRendererView
|
||||
{...baseProps}
|
||||
currentQuestion={{
|
||||
fields: [
|
||||
{
|
||||
@@ -305,8 +243,42 @@ describe('QuestionRenderer', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'next' }));
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
message: ['Q: Response language', 'A: English'].join('\n'),
|
||||
expect(sendMessage).toHaveBeenCalledWith(['Q: Response language', 'A: English'].join('\n'));
|
||||
});
|
||||
|
||||
it('normalizes select questions with choices into button groups', async () => {
|
||||
render(
|
||||
<QuestionRendererView
|
||||
{...baseProps}
|
||||
responseLanguageOptions={[{ label: '简体中文', value: 'zh-CN' }]}
|
||||
currentQuestion={{
|
||||
choices: [
|
||||
{
|
||||
id: 'emoji_lightning',
|
||||
label: '⚡ 闪电 — 快速、高效',
|
||||
payload: {
|
||||
kind: 'patch',
|
||||
patch: {
|
||||
emoji: '⚡',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
id: 'agent_emoji_select',
|
||||
mode: 'select',
|
||||
node: 'agentIdentity',
|
||||
prompt: '最后一步——选个 emoji 作为我的标志。',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '⚡ 闪电 — 快速、高效' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '⚡ 闪电 — 快速、高效' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendMessage).toHaveBeenCalledWith('⚡ 闪电 — 快速、高效');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
QuestionRenderer as BuiltinAgentQuestionRenderer,
|
||||
type QuestionRendererProps as BuiltinAgentQuestionRendererProps,
|
||||
} from '@lobechat/builtin-agent-onboarding/client';
|
||||
import type { UserAgentOnboardingNode, UserAgentOnboardingQuestion } from '@lobechat/types';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { memo } from 'react';
|
||||
|
||||
import EmojiPicker from '@/components/EmojiPicker';
|
||||
import { useConversationStore } from '@/features/Conversation';
|
||||
import { messageStateSelectors } from '@/features/Conversation/store';
|
||||
import ModelSelect from '@/features/ModelSelect';
|
||||
import { localeOptions } from '@/locales/resources';
|
||||
import KlavisServerList from '@/routes/onboarding/components/KlavisServerList';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { settingsSelectors, userGeneralSettingsSelectors } from '@/store/user/selectors';
|
||||
import { type LocaleMode } from '@/types/locale';
|
||||
import { isDev } from '@/utils/env';
|
||||
|
||||
import { ONBOARDING_PRODUCTION_DEFAULT_MODEL } from '../../../const/onboarding';
|
||||
import { useQuestionRendererRuntime } from './questionRendererRuntime';
|
||||
import QuestionRendererView from './QuestionRendererView';
|
||||
|
||||
interface QuestionRendererProps {
|
||||
currentQuestion: UserAgentOnboardingQuestion;
|
||||
@@ -29,98 +12,15 @@ interface QuestionRendererProps {
|
||||
}
|
||||
|
||||
const QuestionRenderer = memo<QuestionRendererProps>(({ currentQuestion, onDismissNode }) => {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const loading = useConversationStore(messageStateSelectors.isInputLoading);
|
||||
const sendMessage = useConversationStore((s) => s.sendMessage);
|
||||
const switchLocale = useGlobalStore((s) => s.switchLocale);
|
||||
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
|
||||
const currentResponseLanguage = useUserStore(
|
||||
userGeneralSettingsSelectors.currentResponseLanguage,
|
||||
const runtime = useQuestionRendererRuntime();
|
||||
|
||||
return (
|
||||
<QuestionRendererView
|
||||
currentQuestion={currentQuestion}
|
||||
onDismissNode={onDismissNode}
|
||||
{...runtime}
|
||||
/>
|
||||
);
|
||||
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
|
||||
const defaultAgentConfig = useUserStore(
|
||||
(s) => settingsSelectors.currentSettings(s).defaultAgent?.config,
|
||||
);
|
||||
const updateDefaultModel = useUserStore((s) => s.updateDefaultModel);
|
||||
|
||||
const defaultModelConfig = isDev ? defaultAgentConfig : ONBOARDING_PRODUCTION_DEFAULT_MODEL;
|
||||
const resolvedQuestion = useMemo<UserAgentOnboardingQuestion>(() => {
|
||||
if (
|
||||
currentQuestion.mode !== 'button_group' ||
|
||||
(currentQuestion.choices && currentQuestion.choices.length > 0)
|
||||
) {
|
||||
return currentQuestion;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentQuestion,
|
||||
description: currentQuestion.description ?? t('agent.telemetryHint'),
|
||||
fields: [
|
||||
{
|
||||
key: 'answer',
|
||||
kind: 'text',
|
||||
label: t('agent.telemetryHint'),
|
||||
placeholder: t('agent.telemetryHint'),
|
||||
},
|
||||
],
|
||||
mode: 'form',
|
||||
};
|
||||
}, [currentQuestion, t]);
|
||||
|
||||
const props: BuiltinAgentQuestionRendererProps = {
|
||||
currentQuestion: resolvedQuestion,
|
||||
currentResponseLanguage,
|
||||
defaultModelConfig,
|
||||
enableKlavis,
|
||||
fixedModelLabel: t('proSettings.model.fixed', {
|
||||
model: ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
|
||||
provider: ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
|
||||
}),
|
||||
isDev,
|
||||
loading,
|
||||
nextLabel: t('next'),
|
||||
onBeforeInfoContinue: async () => {
|
||||
if (!isDev) {
|
||||
await updateDefaultModel(
|
||||
ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
|
||||
ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
|
||||
);
|
||||
}
|
||||
},
|
||||
onChangeDefaultModel: (model, provider) => {
|
||||
void updateDefaultModel(model, provider);
|
||||
},
|
||||
onChangeResponseLanguage: (value) => {
|
||||
switchLocale(value as LocaleMode);
|
||||
void updateGeneralConfig({ responseLanguage: value });
|
||||
},
|
||||
onDismissNode,
|
||||
onSendMessage: async (message) => {
|
||||
await sendMessage({ message });
|
||||
},
|
||||
renderEmojiPicker: ({ onChange, value }) => <EmojiPicker value={value} onChange={onChange} />,
|
||||
renderKlavisList: () => <KlavisServerList />,
|
||||
renderModelSelect: ({ onChange, value }) => (
|
||||
<ModelSelect
|
||||
showAbility={false}
|
||||
size={'large'}
|
||||
style={{ width: '100%' }}
|
||||
value={
|
||||
value?.model
|
||||
? {
|
||||
model: value.model,
|
||||
...(value.provider ? { provider: value.provider } : {}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
responseLanguageOptions: localeOptions,
|
||||
submitLabel: t('next'),
|
||||
};
|
||||
|
||||
return <BuiltinAgentQuestionRenderer {...props} />;
|
||||
});
|
||||
|
||||
QuestionRenderer.displayName = 'QuestionRenderer';
|
||||
|
||||
46
src/features/Onboarding/Agent/QuestionRendererView.tsx
Normal file
46
src/features/Onboarding/Agent/QuestionRendererView.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
QuestionRenderer as BuiltinAgentQuestionRenderer,
|
||||
type QuestionRendererProps as BuiltinAgentQuestionRendererProps,
|
||||
} from '@lobechat/builtin-agent-onboarding/client';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { normalizeQuestionRendererQuestion } from './questionRendererSchema';
|
||||
|
||||
export interface QuestionRendererViewProps extends BuiltinAgentQuestionRendererProps {
|
||||
fallbackQuestionDescription: string;
|
||||
fallbackTextFieldLabel: string;
|
||||
fallbackTextFieldPlaceholder: string;
|
||||
}
|
||||
|
||||
const QuestionRendererView = memo<QuestionRendererViewProps>(
|
||||
({
|
||||
currentQuestion,
|
||||
fallbackQuestionDescription,
|
||||
fallbackTextFieldLabel,
|
||||
fallbackTextFieldPlaceholder,
|
||||
...builtinProps
|
||||
}) => {
|
||||
const resolvedQuestion = useMemo(
|
||||
() =>
|
||||
normalizeQuestionRendererQuestion(currentQuestion, {
|
||||
description: fallbackQuestionDescription,
|
||||
label: fallbackTextFieldLabel,
|
||||
placeholder: fallbackTextFieldPlaceholder,
|
||||
}),
|
||||
[
|
||||
currentQuestion,
|
||||
fallbackQuestionDescription,
|
||||
fallbackTextFieldLabel,
|
||||
fallbackTextFieldPlaceholder,
|
||||
],
|
||||
);
|
||||
|
||||
return <BuiltinAgentQuestionRenderer currentQuestion={resolvedQuestion} {...builtinProps} />;
|
||||
},
|
||||
);
|
||||
|
||||
QuestionRendererView.displayName = 'QuestionRendererView';
|
||||
|
||||
export default QuestionRendererView;
|
||||
@@ -53,4 +53,68 @@ describe('resolveAgentOnboardingContext', () => {
|
||||
topicId: 'topic-bootstrap',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to stored questionSurface when bootstrap currentQuestion is empty', () => {
|
||||
const result = resolveAgentOnboardingContext({
|
||||
bootstrapContext: {
|
||||
agentOnboarding: {
|
||||
activeTopicId: 'topic-bootstrap',
|
||||
completedNodes: [],
|
||||
version: 1,
|
||||
},
|
||||
context: {
|
||||
currentQuestion: undefined,
|
||||
},
|
||||
topicId: 'topic-bootstrap',
|
||||
},
|
||||
storedAgentOnboarding: {
|
||||
completedNodes: [],
|
||||
questionSurface: {
|
||||
node: 'agentIdentity',
|
||||
question: {
|
||||
id: 'agent_identity_001',
|
||||
mode: 'button_group',
|
||||
node: 'agentIdentity',
|
||||
prompt: '贾维斯的气质应该是什么样的?',
|
||||
},
|
||||
updatedAt: '2026-03-24T00:00:00.000Z',
|
||||
},
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
currentQuestion: {
|
||||
id: 'agent_identity_001',
|
||||
mode: 'button_group',
|
||||
node: 'agentIdentity',
|
||||
prompt: '贾维斯的气质应该是什么样的?',
|
||||
},
|
||||
topicId: 'topic-bootstrap',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not surface questionSurface for completed nodes', () => {
|
||||
const result = resolveAgentOnboardingContext({
|
||||
storedAgentOnboarding: {
|
||||
completedNodes: ['agentIdentity'],
|
||||
questionSurface: {
|
||||
node: 'agentIdentity',
|
||||
question: {
|
||||
id: 'agent_identity_001',
|
||||
mode: 'button_group',
|
||||
node: 'agentIdentity',
|
||||
prompt: '贾维斯的气质应该是什么样的?',
|
||||
},
|
||||
updatedAt: '2026-03-24T00:00:00.000Z',
|
||||
},
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
currentQuestion: undefined,
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,22 @@ interface ResolveAgentOnboardingContextParams {
|
||||
storedAgentOnboarding?: UserAgentOnboarding;
|
||||
}
|
||||
|
||||
const resolveQuestionFromSurface = (state?: UserAgentOnboarding) => {
|
||||
const questionSurface = state?.questionSurface;
|
||||
|
||||
if (!questionSurface) return undefined;
|
||||
if (state?.completedNodes?.includes(questionSurface.node)) return undefined;
|
||||
|
||||
return questionSurface.question;
|
||||
};
|
||||
|
||||
export const resolveAgentOnboardingContext = ({
|
||||
bootstrapContext,
|
||||
storedAgentOnboarding,
|
||||
}: ResolveAgentOnboardingContextParams) => ({
|
||||
currentQuestion: bootstrapContext?.context.currentQuestion,
|
||||
currentQuestion:
|
||||
bootstrapContext?.context.currentQuestion ||
|
||||
resolveQuestionFromSurface(storedAgentOnboarding) ||
|
||||
resolveQuestionFromSurface(bootstrapContext?.agentOnboarding),
|
||||
topicId: bootstrapContext?.topicId ?? storedAgentOnboarding?.activeTopicId,
|
||||
});
|
||||
|
||||
117
src/features/Onboarding/Agent/questionRendererRuntime.tsx
Normal file
117
src/features/Onboarding/Agent/questionRendererRuntime.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import EmojiPicker from '@/components/EmojiPicker';
|
||||
import { useConversationStore } from '@/features/Conversation';
|
||||
import { messageStateSelectors } from '@/features/Conversation/store';
|
||||
import ModelSelect from '@/features/ModelSelect';
|
||||
import { localeOptions } from '@/locales/resources';
|
||||
import KlavisServerList from '@/routes/onboarding/components/KlavisServerList';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { settingsSelectors, userGeneralSettingsSelectors } from '@/store/user/selectors';
|
||||
import { type LocaleMode } from '@/types/locale';
|
||||
import { isDev } from '@/utils/env';
|
||||
|
||||
import { ONBOARDING_PRODUCTION_DEFAULT_MODEL } from '../../../const/onboarding';
|
||||
import type { QuestionRendererViewProps } from './QuestionRendererView';
|
||||
|
||||
export interface QuestionRendererRuntimeProps extends Omit<
|
||||
QuestionRendererViewProps,
|
||||
'currentQuestion' | 'onDismissNode'
|
||||
> {}
|
||||
|
||||
export const useQuestionRendererRuntime = (): QuestionRendererRuntimeProps => {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const loading = useConversationStore(messageStateSelectors.isInputLoading);
|
||||
const sendMessage = useConversationStore((s) => s.sendMessage);
|
||||
const switchLocale = useGlobalStore((s) => s.switchLocale);
|
||||
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
|
||||
const currentResponseLanguage = useUserStore(
|
||||
userGeneralSettingsSelectors.currentResponseLanguage,
|
||||
);
|
||||
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
|
||||
const defaultAgentConfig = useUserStore(
|
||||
(s) => settingsSelectors.currentSettings(s).defaultAgent?.config,
|
||||
);
|
||||
const updateDefaultModel = useUserStore((s) => s.updateDefaultModel);
|
||||
|
||||
const defaultModelConfig = isDev ? defaultAgentConfig : ONBOARDING_PRODUCTION_DEFAULT_MODEL;
|
||||
const telemetryHint = t('agent.telemetryHint');
|
||||
const nextLabel = t('next');
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
currentResponseLanguage,
|
||||
defaultModelConfig,
|
||||
enableKlavis,
|
||||
fallbackQuestionDescription: telemetryHint,
|
||||
fallbackTextFieldLabel: telemetryHint,
|
||||
fallbackTextFieldPlaceholder: telemetryHint,
|
||||
fixedModelLabel: t('proSettings.model.fixed', {
|
||||
model: ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
|
||||
provider: ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
|
||||
}),
|
||||
isDev,
|
||||
loading,
|
||||
nextLabel,
|
||||
onBeforeInfoContinue: async () => {
|
||||
if (!isDev) {
|
||||
await updateDefaultModel(
|
||||
ONBOARDING_PRODUCTION_DEFAULT_MODEL.model,
|
||||
ONBOARDING_PRODUCTION_DEFAULT_MODEL.provider,
|
||||
);
|
||||
}
|
||||
},
|
||||
onChangeDefaultModel: (model: string, provider: string) => {
|
||||
void updateDefaultModel(model, provider);
|
||||
},
|
||||
onChangeResponseLanguage: (value: string) => {
|
||||
switchLocale(value as LocaleMode);
|
||||
void updateGeneralConfig({ responseLanguage: value });
|
||||
},
|
||||
onSendMessage: async (message: string) => {
|
||||
// Dismiss the inline onboarding widget immediately after dispatch.
|
||||
// The full chat send lifecycle also awaits runtime streaming, which is too late
|
||||
// for this UI pattern because the question should disappear once submitted.
|
||||
void sendMessage({ message }).catch(console.error);
|
||||
},
|
||||
renderEmojiPicker: ({ onChange, value }) => <EmojiPicker value={value} onChange={onChange} />,
|
||||
renderKlavisList: () => <KlavisServerList />,
|
||||
renderModelSelect: ({ onChange, value }) => (
|
||||
<ModelSelect
|
||||
showAbility={false}
|
||||
size={'large'}
|
||||
style={{ width: '100%' }}
|
||||
value={
|
||||
value?.model
|
||||
? {
|
||||
model: value.model,
|
||||
...(value.provider ? { provider: value.provider } : {}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
responseLanguageOptions: localeOptions,
|
||||
submitLabel: nextLabel,
|
||||
}),
|
||||
[
|
||||
currentResponseLanguage,
|
||||
defaultModelConfig,
|
||||
enableKlavis,
|
||||
loading,
|
||||
nextLabel,
|
||||
sendMessage,
|
||||
switchLocale,
|
||||
t,
|
||||
telemetryHint,
|
||||
updateDefaultModel,
|
||||
updateGeneralConfig,
|
||||
],
|
||||
);
|
||||
};
|
||||
42
src/features/Onboarding/Agent/questionRendererSchema.ts
Normal file
42
src/features/Onboarding/Agent/questionRendererSchema.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { UserAgentOnboardingQuestion } from '@lobechat/types';
|
||||
|
||||
interface QuestionRendererFallbackCopy {
|
||||
description: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export const normalizeQuestionRendererQuestion = (
|
||||
question: UserAgentOnboardingQuestion,
|
||||
fallbackCopy: QuestionRendererFallbackCopy,
|
||||
): UserAgentOnboardingQuestion => {
|
||||
if (
|
||||
question.mode === 'select' &&
|
||||
(!question.fields || question.fields.length === 0) &&
|
||||
question.choices &&
|
||||
question.choices.length > 0
|
||||
) {
|
||||
return {
|
||||
...question,
|
||||
mode: 'button_group',
|
||||
};
|
||||
}
|
||||
|
||||
if (question.mode !== 'button_group' || (question.choices && question.choices.length > 0)) {
|
||||
return question;
|
||||
}
|
||||
|
||||
return {
|
||||
...question,
|
||||
description: question.description ?? fallbackCopy.description,
|
||||
fields: [
|
||||
{
|
||||
key: 'answer',
|
||||
kind: 'text',
|
||||
label: fallbackCopy.label,
|
||||
placeholder: fallbackCopy.placeholder,
|
||||
},
|
||||
],
|
||||
mode: 'form',
|
||||
};
|
||||
};
|
||||
@@ -204,13 +204,12 @@ export const userRouter = router({
|
||||
getOnboardingState: userProcedure.query(async ({ ctx }) => {
|
||||
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
|
||||
|
||||
return onboardingService.getState({ issueReadToken: true });
|
||||
return onboardingService.getState();
|
||||
}),
|
||||
|
||||
saveOnboardingAnswer: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
readToken: z.string().min(1),
|
||||
updates: z.array(UserAgentOnboardingUpdateSchema).min(1),
|
||||
}),
|
||||
)
|
||||
@@ -225,7 +224,6 @@ export const userRouter = router({
|
||||
z.object({
|
||||
node: UserAgentOnboardingNodeSchema,
|
||||
question: UserAgentOnboardingQuestionDraftSchema,
|
||||
readToken: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -238,39 +236,32 @@ export const userRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
node: UserAgentOnboardingNodeSchema,
|
||||
readToken: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
|
||||
|
||||
return onboardingService.completeCurrentStep(input.node, input.readToken);
|
||||
return onboardingService.completeCurrentStep(input.node);
|
||||
}),
|
||||
|
||||
returnToOnboarding: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
readToken: z.string().min(1),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
|
||||
|
||||
return onboardingService.returnToOnboarding(input.readToken, input.reason);
|
||||
return onboardingService.returnToOnboarding(input.reason);
|
||||
}),
|
||||
|
||||
finishOnboarding: userProcedure
|
||||
.input(
|
||||
z.object({
|
||||
readToken: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
|
||||
finishOnboarding: userProcedure.input(z.object({})).mutation(async ({ ctx, input }) => {
|
||||
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
|
||||
void input;
|
||||
|
||||
return onboardingService.finishOnboarding(input.readToken);
|
||||
}),
|
||||
return onboardingService.finishOnboarding();
|
||||
}),
|
||||
|
||||
resetAgentOnboarding: userProcedure.mutation(async ({ ctx }) => {
|
||||
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
|
||||
|
||||
@@ -105,16 +105,14 @@ describe('OnboardingService', () => {
|
||||
vi.mocked(AgentService).mockImplementation(() => mockAgentService as any);
|
||||
});
|
||||
|
||||
const issueReadToken = async (service: OnboardingService) => {
|
||||
const context = await service.getState({ issueReadToken: true });
|
||||
|
||||
return context.control.readToken!;
|
||||
};
|
||||
|
||||
it('resets legacy onboarding nodes to the new conversational flow', async () => {
|
||||
it('resets legacy onboarding nodes to the conversational flow', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: ['telemetry', 'fullName'],
|
||||
currentNode: 'fullName',
|
||||
executionGuard: {
|
||||
issuedAt: '2026-03-24T00:00:00.000Z',
|
||||
readToken: 'legacy-token',
|
||||
},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
};
|
||||
|
||||
@@ -126,7 +124,7 @@ describe('OnboardingService', () => {
|
||||
expect(context.committed.agentIdentity).toBeUndefined();
|
||||
});
|
||||
|
||||
it('issues control metadata and a read token for tool execution state reads', async () => {
|
||||
it('returns lightweight control metadata without read tokens', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: [],
|
||||
draft: {},
|
||||
@@ -134,43 +132,13 @@ describe('OnboardingService', () => {
|
||||
};
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const context = await service.getState({ issueReadToken: true });
|
||||
const context = await service.getState();
|
||||
|
||||
expect(context.control.readTokenRequired).toBe(true);
|
||||
expect(context.control.readToken).toBeTruthy();
|
||||
expect(context.control.allowedTools).toContain('getOnboardingState');
|
||||
expect(context.control.allowedTools).toContain('askUserQuestion');
|
||||
expect(context.control.allowedTools).toContain('saveAnswer');
|
||||
});
|
||||
|
||||
it('rejects onboarding actions when the latest state read token is missing or stale', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: [],
|
||||
draft: {},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
};
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const result = await service.saveAnswer({
|
||||
readToken: 'stale-token',
|
||||
updates: [
|
||||
{
|
||||
node: 'agentIdentity',
|
||||
patch: {
|
||||
emoji: '🦞',
|
||||
name: 'Lobster',
|
||||
nature: 'direct',
|
||||
vibe: 'steady',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe('STATE_READ_REQUIRED');
|
||||
expect(result.instruction).toContain(
|
||||
'Call getOnboardingState immediately before any other onboarding tool.',
|
||||
);
|
||||
expect('readToken' in context.control).toBe(false);
|
||||
expect('readTokenRequired' in context.control).toBe(false);
|
||||
});
|
||||
|
||||
it('commits agent identity and advances to user identity', async () => {
|
||||
@@ -182,7 +150,6 @@ describe('OnboardingService', () => {
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const result = await service.saveAnswer({
|
||||
readToken: await issueReadToken(service),
|
||||
updates: [
|
||||
{
|
||||
node: 'agentIdentity',
|
||||
@@ -199,6 +166,7 @@ describe('OnboardingService', () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.activeNode).toBe('userIdentity');
|
||||
expect(persistedUserState.agentOnboarding.agentIdentity).toEqual({
|
||||
emoji: '🫖',
|
||||
name: '小七',
|
||||
@@ -208,26 +176,7 @@ describe('OnboardingService', () => {
|
||||
expect(persistedUserState.agentOnboarding.completedNodes).toEqual(['agentIdentity']);
|
||||
});
|
||||
|
||||
it('preserves node-scoped flat patch fields in the update schema', () => {
|
||||
const parsed = UserAgentOnboardingUpdateSchema.parse({
|
||||
node: 'agentIdentity',
|
||||
patch: {
|
||||
emoji: '🐍',
|
||||
name: '小齐',
|
||||
nature: 'Sharp, playful AI sidekick with insights',
|
||||
vibe: 'Serpent-like: sharp, witty, and insightful',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.patch).toEqual({
|
||||
emoji: '🐍',
|
||||
name: '小齐',
|
||||
nature: 'Sharp, playful AI sidekick with insights',
|
||||
vibe: 'Serpent-like: sharp, witty, and insightful',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts flat agent identity payloads scoped by node', async () => {
|
||||
it('stores partial drafts and reports missing required fields', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: [],
|
||||
draft: {},
|
||||
@@ -236,40 +185,6 @@ describe('OnboardingService', () => {
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const result = await service.saveAnswer({
|
||||
readToken: await issueReadToken(service),
|
||||
updates: [
|
||||
{
|
||||
node: 'agentIdentity',
|
||||
patch: {
|
||||
emoji: '🐍',
|
||||
name: '小齐',
|
||||
nature: 'Sharp, playful AI sidekick with insights',
|
||||
vibe: 'Serpent-like: sharp, witty, and insightful',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(persistedUserState.agentOnboarding.agentIdentity).toEqual({
|
||||
emoji: '🐍',
|
||||
name: '小齐',
|
||||
nature: 'Sharp, playful AI sidekick with insights',
|
||||
vibe: 'Serpent-like: sharp, witty, and insightful',
|
||||
});
|
||||
expect(persistedUserState.agentOnboarding.completedNodes).toEqual(['agentIdentity']);
|
||||
});
|
||||
|
||||
it('stores partial agent identity drafts without committing the node', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: [],
|
||||
draft: {},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
};
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const result = await service.saveAnswer({
|
||||
readToken: await issueReadToken(service),
|
||||
updates: [
|
||||
{
|
||||
node: 'agentIdentity',
|
||||
@@ -286,14 +201,13 @@ describe('OnboardingService', () => {
|
||||
missingFields: ['emoji', 'name', 'nature'],
|
||||
status: 'partial',
|
||||
});
|
||||
expect(result.content).toContain('Saved a partial draft');
|
||||
expect(persistedUserState.agentOnboarding.draft.agentIdentity).toEqual({
|
||||
vibe: '活泼',
|
||||
});
|
||||
expect(persistedUserState.agentOnboarding.completedNodes).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves later-step drafts when the model calls userIdentity too early', async () => {
|
||||
it('rejects writes to a non-active node and does not preserve later drafts', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: [],
|
||||
draft: {},
|
||||
@@ -302,7 +216,6 @@ describe('OnboardingService', () => {
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const result = await service.saveAnswer({
|
||||
readToken: await issueReadToken(service),
|
||||
updates: [
|
||||
{
|
||||
node: 'userIdentity',
|
||||
@@ -319,71 +232,14 @@ describe('OnboardingService', () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.content).toContain('saved the later-step draft');
|
||||
expect(result.content).toContain('Stay on agentIdentity');
|
||||
expect(result.error?.code).toBe('NODE_MISMATCH');
|
||||
expect(result.activeNode).toBe('agentIdentity');
|
||||
expect(result.requestedNode).toBe('userIdentity');
|
||||
expect(result.instruction).toContain('Do not call userIdentity yet');
|
||||
expect(result.currentQuestion).toBeUndefined();
|
||||
expect(result.mismatch).toBe(true);
|
||||
expect(result.savedDraftFields).toEqual(['userIdentity']);
|
||||
expect(persistedUserState.agentOnboarding.draft.userIdentity).toEqual({
|
||||
domainExpertise: 'AI application development',
|
||||
name: 'Ada',
|
||||
professionalRole: 'Engineer',
|
||||
summary: 'Ada builds AI application features and infra.',
|
||||
});
|
||||
expect(persistedUserState.agentOnboarding.draft.userIdentity).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not derive a current question from onboarding state without a stored question surface', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: [],
|
||||
currentNode: 'agentIdentity',
|
||||
draft: {
|
||||
responseLanguage: 'zh-CN',
|
||||
},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
};
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const context = await service.getState();
|
||||
|
||||
expect(context.activeNode).toBe('agentIdentity');
|
||||
expect(context.currentQuestion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not derive later questions from completed nodes without a stored question surface', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: ['agentIdentity', 'userIdentity', 'workStyle', 'workContext', 'painPoints'],
|
||||
currentNode: 'agentIdentity',
|
||||
draft: {
|
||||
responseLanguage: 'zh-CN',
|
||||
},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
};
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const context = await service.getState();
|
||||
|
||||
expect(context.activeNode).toBe('responseLanguage');
|
||||
expect(context.currentQuestion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns no current question when the active node has no stored question surface', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: ['agentIdentity'],
|
||||
draft: {},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
};
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const context = await service.getState();
|
||||
|
||||
expect(context.activeNode).toBe('userIdentity');
|
||||
expect(context.currentQuestion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('supports batch updates across consecutive onboarding nodes', async () => {
|
||||
it('supports sequential batch updates across consecutive nodes', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: [],
|
||||
draft: {},
|
||||
@@ -392,28 +248,21 @@ describe('OnboardingService', () => {
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const result = await service.saveAnswer({
|
||||
readToken: await issueReadToken(service),
|
||||
updates: [
|
||||
{
|
||||
node: 'agentIdentity',
|
||||
patch: {
|
||||
agentIdentity: {
|
||||
emoji: '🦊',
|
||||
name: '小七',
|
||||
nature: 'a fox-like AI collaborator',
|
||||
vibe: 'sharp and warm',
|
||||
},
|
||||
emoji: '🦊',
|
||||
name: '小七',
|
||||
nature: 'a fox-like AI collaborator',
|
||||
vibe: 'sharp and warm',
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'userIdentity',
|
||||
patch: {
|
||||
userIdentity: {
|
||||
domainExpertise: 'AI application development',
|
||||
name: 'Ada',
|
||||
professionalRole: 'Engineer',
|
||||
summary: 'Ada builds AI application features and infra.',
|
||||
},
|
||||
summary: 'Ada builds AI application features and infra.',
|
||||
professionalRole: 'Engineer',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -422,9 +271,10 @@ describe('OnboardingService', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.processedNodes).toEqual(['agentIdentity', 'userIdentity']);
|
||||
expect(result.activeNode).toBe('workStyle');
|
||||
expect(result.currentQuestion).toBeUndefined();
|
||||
expect(persistedUserState.agentOnboarding.agentIdentity?.name).toBe('小七');
|
||||
expect(persistedUserState.agentOnboarding.profile?.identity?.name).toBe('Ada');
|
||||
expect(persistedUserState.agentOnboarding.profile?.identity).toEqual({
|
||||
professionalRole: 'Engineer',
|
||||
summary: 'Ada builds AI application features and infra.',
|
||||
});
|
||||
});
|
||||
|
||||
it('stores the current question for the active onboarding node', async () => {
|
||||
@@ -453,7 +303,6 @@ describe('OnboardingService', () => {
|
||||
mode: 'button_group',
|
||||
prompt: 'AI-generated preset',
|
||||
},
|
||||
readToken: await issueReadToken(service),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@@ -465,17 +314,9 @@ describe('OnboardingService', () => {
|
||||
node: 'agentIdentity',
|
||||
}),
|
||||
);
|
||||
expect(persistedUserState.agentOnboarding.questionSurface).toEqual({
|
||||
node: 'agentIdentity',
|
||||
question: expect.objectContaining({
|
||||
id: 'agent-identity-ai-preset',
|
||||
node: 'agentIdentity',
|
||||
}),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the current question after the onboarding node advances', async () => {
|
||||
it('clears the current question after the active node advances', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: [],
|
||||
draft: {},
|
||||
@@ -494,17 +335,14 @@ describe('OnboardingService', () => {
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
await service.saveAnswer({
|
||||
readToken: await issueReadToken(service),
|
||||
updates: [
|
||||
{
|
||||
node: 'agentIdentity',
|
||||
patch: {
|
||||
agentIdentity: {
|
||||
emoji: '🫖',
|
||||
name: '小七',
|
||||
nature: 'an odd little AI housemate',
|
||||
vibe: 'warm and sharp',
|
||||
},
|
||||
emoji: '🫖',
|
||||
name: '小七',
|
||||
nature: 'an odd little AI housemate',
|
||||
vibe: 'warm and sharp',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -513,74 +351,36 @@ describe('OnboardingService', () => {
|
||||
const context = await service.getState();
|
||||
|
||||
expect(context.activeNode).toBe('userIdentity');
|
||||
expect(persistedUserState.agentOnboarding.questionSurface).toBeUndefined();
|
||||
expect(context.currentQuestion).toBeUndefined();
|
||||
expect(persistedUserState.agentOnboarding.questionSurface).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces a committed profile across the new onboarding dimensions', async () => {
|
||||
it('commits an already complete draft through completeCurrentStep', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
agentIdentity: {
|
||||
emoji: '🫖',
|
||||
name: '小七',
|
||||
nature: 'an odd little AI housemate',
|
||||
vibe: 'warm and sharp',
|
||||
},
|
||||
completedNodes: ['agentIdentity', 'userIdentity', 'workStyle', 'workContext', 'painPoints'],
|
||||
draft: {},
|
||||
profile: {
|
||||
currentFocus: 'shipping a 0-1 B2B data platform this quarter',
|
||||
identity: {
|
||||
domainExpertise: 'B2B SaaS',
|
||||
name: 'Ada',
|
||||
professionalRole: 'Product Manager',
|
||||
summary: 'Ada is a B2B SaaS PM building a data platform from 0 to 1.',
|
||||
},
|
||||
interests: ['product strategy', 'data platforms'],
|
||||
painPoints: {
|
||||
blockedBy: ['cross-team alignment'],
|
||||
frustrations: ['spec churn'],
|
||||
noTimeFor: ['user research synthesis'],
|
||||
summary:
|
||||
'Execution gets dragged down by alignment overhead and constant requirement churn.',
|
||||
},
|
||||
workContext: {
|
||||
activeProjects: ['data platform 0-1'],
|
||||
currentFocus: 'define the MVP and prove adoption',
|
||||
interests: ['product strategy', 'data platforms'],
|
||||
summary:
|
||||
'She is focused on MVP definition, adoption, and the operating rhythm around the launch.',
|
||||
thisQuarter: 'launch the first usable version',
|
||||
thisWeek: 'close scope and unblock engineering',
|
||||
tools: ['Notion', 'Figma', 'SQL'],
|
||||
},
|
||||
completedNodes: ['agentIdentity', 'userIdentity'],
|
||||
draft: {
|
||||
workStyle: {
|
||||
communicationStyle: 'direct',
|
||||
decisionMaking: 'data-informed but fast',
|
||||
socialMode: 'collaborative',
|
||||
summary: 'She likes clear trade-offs, quick synthesis, and direct communication.',
|
||||
thinkingPreferences: 'structured',
|
||||
workStyle: 'fast-moving',
|
||||
summary: 'Prefers direct communication and quick synthesis.',
|
||||
},
|
||||
},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
};
|
||||
persistedUserState.fullName = 'Ada';
|
||||
persistedUserState.interests = ['product strategy', 'data platforms'];
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const context = await service.getState();
|
||||
const committedAgentIdentity = context.committed.agentIdentity as any;
|
||||
const committedProfile = context.committed.profile as any;
|
||||
const result = await service.completeCurrentStep('workStyle');
|
||||
|
||||
expect(committedAgentIdentity?.name).toBe('小七');
|
||||
expect(context.currentQuestion).toBeUndefined();
|
||||
expect(committedProfile?.identity?.professionalRole).toBe('Product Manager');
|
||||
expect(committedProfile?.workStyle?.decisionMaking).toBe('data-informed but fast');
|
||||
expect(committedProfile?.workContext?.tools).toEqual(['Notion', 'Figma', 'SQL']);
|
||||
expect(committedProfile?.painPoints?.blockedBy).toEqual(['cross-team alignment']);
|
||||
expect(result.success).toBe(true);
|
||||
expect(persistedUserState.agentOnboarding.completedNodes).toEqual([
|
||||
'agentIdentity',
|
||||
'userIdentity',
|
||||
'workStyle',
|
||||
]);
|
||||
expect(persistedUserState.agentOnboarding.profile?.workStyle).toEqual({
|
||||
summary: 'Prefers direct communication and quick synthesis.',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows finish when summary is the derived active step even if legacy currentNode is stale', async () => {
|
||||
it('rejects saveAnswer on summary and only finishes from summary', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: [
|
||||
'agentIdentity',
|
||||
@@ -591,19 +391,47 @@ describe('OnboardingService', () => {
|
||||
'responseLanguage',
|
||||
'proSettings',
|
||||
],
|
||||
currentNode: 'agentIdentity',
|
||||
draft: {},
|
||||
version: CURRENT_ONBOARDING_VERSION,
|
||||
};
|
||||
|
||||
const service = new OnboardingService(mockDb, userId);
|
||||
const result = await service.finishOnboarding(await issueReadToken(service));
|
||||
const saveResult = await service.saveAnswer({
|
||||
updates: [
|
||||
{
|
||||
node: 'summary',
|
||||
patch: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const finishResult = await service.finishOnboarding();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(saveResult.success).toBe(false);
|
||||
expect(saveResult.error?.code).toBe('INCOMPLETE_NODE_DATA');
|
||||
expect(finishResult.success).toBe(true);
|
||||
expect(persistedUserState.agentOnboarding.finishedAt).toBeTruthy();
|
||||
expect(persistedUserState.agentOnboarding.completedNodes).toContain('summary');
|
||||
});
|
||||
|
||||
it('preserves flat node-scoped patch fields in the update schema', () => {
|
||||
const parsed = UserAgentOnboardingUpdateSchema.parse({
|
||||
node: 'agentIdentity',
|
||||
patch: {
|
||||
emoji: '🐍',
|
||||
name: '小齐',
|
||||
nature: 'Sharp, playful AI sidekick with insights',
|
||||
vibe: 'Serpent-like: sharp, witty, and insightful',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.patch).toEqual({
|
||||
emoji: '🐍',
|
||||
name: '小齐',
|
||||
nature: 'Sharp, playful AI sidekick with insights',
|
||||
vibe: 'Serpent-like: sharp, witty, and insightful',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a persisted welcome message for a new onboarding topic', async () => {
|
||||
persistedUserState.agentOnboarding = {
|
||||
completedNodes: ['agentIdentity', 'userIdentity'],
|
||||
@@ -620,11 +448,6 @@ describe('OnboardingService', () => {
|
||||
title: 'Onboarding',
|
||||
trigger: 'chat',
|
||||
});
|
||||
expect(mockMessageModel.query).toHaveBeenCalledWith({
|
||||
agentId: 'builtin-agent-1',
|
||||
pageSize: 1,
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
expect(mockMessageModel.create).toHaveBeenCalledWith({
|
||||
agentId: 'builtin-agent-1',
|
||||
content: expect.stringContaining('Onboarding'),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
|
||||
import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
|
||||
import type {
|
||||
@@ -132,6 +130,45 @@ interface OnboardingNodeDraftState {
|
||||
status: 'complete' | 'empty' | 'partial';
|
||||
}
|
||||
|
||||
interface OnboardingError {
|
||||
code: 'INCOMPLETE_NODE_DATA' | 'INVALID_PATCH_SHAPE' | 'NODE_MISMATCH' | 'ONBOARDING_COMPLETE';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface CommitStepResult {
|
||||
content: string;
|
||||
control: UserAgentOnboardingControl;
|
||||
currentQuestion?: UserAgentOnboardingQuestion;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
interface ProposePatchResult {
|
||||
activeNode?: UserAgentOnboardingNode;
|
||||
activeNodeDraftState?: OnboardingNodeDraftState;
|
||||
committedValue?: unknown;
|
||||
content: string;
|
||||
control?: UserAgentOnboardingControl;
|
||||
currentQuestion?: UserAgentOnboardingQuestion;
|
||||
draft: UserAgentOnboardingDraft;
|
||||
error?: OnboardingError;
|
||||
mismatch?: boolean;
|
||||
nextAction: 'ask' | 'commit' | 'confirm';
|
||||
processedNodes?: UserAgentOnboardingNode[];
|
||||
requestedNode?: UserAgentOnboardingNode;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
interface AskQuestionResult {
|
||||
activeNode?: UserAgentOnboardingNode;
|
||||
content: string;
|
||||
control?: UserAgentOnboardingControl;
|
||||
currentQuestion?: UserAgentOnboardingQuestion;
|
||||
mismatch?: boolean;
|
||||
requestedNode?: UserAgentOnboardingNode;
|
||||
storedQuestionId?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const getScopedPatch = (node: UserAgentOnboardingNode, patch: OnboardingPatchInput) => {
|
||||
const nestedKey = node === 'proSettings' ? 'defaultModel' : node;
|
||||
const nestedPatch = isRecord(patch[nestedKey]) ? patch[nestedKey] : undefined;
|
||||
@@ -526,45 +563,14 @@ const getNodeDraftState = (
|
||||
};
|
||||
};
|
||||
|
||||
const getNodeRecoveryInstruction = (node: UserAgentOnboardingNode) => {
|
||||
switch (node) {
|
||||
case 'agentIdentity': {
|
||||
return 'Stay on agentIdentity. Do not call userIdentity yet. Ask the user to define your name, nature, vibe, and emoji, or offer concrete defaults and confirm one.';
|
||||
}
|
||||
case 'userIdentity': {
|
||||
return 'Stay on userIdentity. Ask who the user is, what they do, and what domain they work in. Capture a concise summary plus any available name, role, or domain expertise.';
|
||||
}
|
||||
case 'workStyle': {
|
||||
return 'Stay on workStyle. Ask how the user thinks, decides, communicates, and works with others. Capture a concise summary of their style.';
|
||||
}
|
||||
case 'workContext': {
|
||||
return 'Stay on workContext. Ask about current focus, active projects, interests, tools, and near-term priorities. Capture a concise summary plus any concrete details.';
|
||||
}
|
||||
case 'painPoints': {
|
||||
return 'Stay on painPoints. Ask what is blocked, frustrating, repetitive, or never gets enough time. Capture the actual friction, not generic preferences.';
|
||||
}
|
||||
case 'responseLanguage': {
|
||||
return 'Stay on responseLanguage. Ask which language the user wants as the default reply language.';
|
||||
}
|
||||
case 'proSettings': {
|
||||
return 'Stay on proSettings. Capture a default model/provider only if the user gives one or says they are ready to continue.';
|
||||
}
|
||||
case 'summary': {
|
||||
return 'Stay on summary. Summarize the committed setup, ask only whether the summary is accurate, and call finishOnboarding immediately after a light confirmation. Do not ask what to do next inside onboarding.';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildOnboardingControl = ({
|
||||
activeNode,
|
||||
activeNodeDraftState,
|
||||
currentQuestion,
|
||||
readToken,
|
||||
}: {
|
||||
activeNode?: UserAgentOnboardingNode;
|
||||
activeNodeDraftState?: OnboardingNodeDraftState;
|
||||
currentQuestion?: UserAgentOnboardingQuestion;
|
||||
readToken?: string;
|
||||
}): UserAgentOnboardingControl => {
|
||||
const missingFields = activeNodeDraftState?.missingFields ?? [];
|
||||
const canCompleteCurrentStep =
|
||||
@@ -574,14 +580,11 @@ const buildOnboardingControl = ({
|
||||
|
||||
if (activeNode) {
|
||||
if (activeNode === 'summary') {
|
||||
allowedTools.push(currentQuestion ? 'finishOnboarding' : 'askUserQuestion');
|
||||
} else {
|
||||
allowedTools.push('saveAnswer');
|
||||
allowedTools.push('askUserQuestion');
|
||||
|
||||
if (canCompleteCurrentStep) {
|
||||
allowedTools.push('completeCurrentStep');
|
||||
}
|
||||
if (currentQuestion) allowedTools.push('finishOnboarding');
|
||||
} else {
|
||||
allowedTools.push('saveAnswer', 'askUserQuestion');
|
||||
if (canCompleteCurrentStep) allowedTools.push('completeCurrentStep');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,8 +597,6 @@ const buildOnboardingControl = ({
|
||||
canCompleteCurrentStep,
|
||||
canFinish,
|
||||
missingFields,
|
||||
...(readToken ? { readToken } : {}),
|
||||
readTokenRequired: true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -610,46 +611,6 @@ const attachNodeToQuestion = (
|
||||
question: UserAgentOnboardingQuestionDraft,
|
||||
): UserAgentOnboardingQuestion => ({ ...question, node });
|
||||
|
||||
interface ProposePatchResult {
|
||||
activeNode?: UserAgentOnboardingNode;
|
||||
activeNodeDraftState?: OnboardingNodeDraftState;
|
||||
committedValue?: unknown;
|
||||
content: string;
|
||||
control?: UserAgentOnboardingControl;
|
||||
currentQuestion?: UserAgentOnboardingQuestion;
|
||||
draft: UserAgentOnboardingDraft;
|
||||
error?: {
|
||||
code:
|
||||
| 'INCOMPLETE_NODE_DATA'
|
||||
| 'INVALID_PATCH_SHAPE'
|
||||
| 'NODE_MISMATCH'
|
||||
| 'ONBOARDING_COMPLETE'
|
||||
| 'STATE_READ_REQUIRED';
|
||||
message: string;
|
||||
};
|
||||
instruction?: string;
|
||||
mismatch?: boolean;
|
||||
nextAction: 'ask' | 'commit' | 'confirm';
|
||||
processedNodes?: UserAgentOnboardingNode[];
|
||||
requestedNode?: UserAgentOnboardingNode;
|
||||
requiresReadBeforeNextTool?: boolean;
|
||||
savedDraftFields?: (keyof UserAgentOnboardingDraft)[];
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
interface AskQuestionResult {
|
||||
activeNode?: UserAgentOnboardingNode;
|
||||
content: string;
|
||||
control?: UserAgentOnboardingControl;
|
||||
currentQuestion?: UserAgentOnboardingQuestion;
|
||||
instruction?: string;
|
||||
mismatch?: boolean;
|
||||
requestedNode?: UserAgentOnboardingNode;
|
||||
requiresReadBeforeNextTool?: boolean;
|
||||
storedQuestionId?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export class OnboardingService {
|
||||
private readonly agentService: AgentService;
|
||||
private readonly messageModel: MessageModel;
|
||||
@@ -679,18 +640,20 @@ export class OnboardingService {
|
||||
|
||||
const mergedState = merge(defaultAgentOnboardingState(), state ?? {}) as UserAgentOnboarding & {
|
||||
currentNode?: UserAgentOnboardingNode;
|
||||
executionGuard?: unknown;
|
||||
};
|
||||
const { currentNode: legacyCurrentNode, ...nextState } = mergedState;
|
||||
const {
|
||||
currentNode: legacyCurrentNode,
|
||||
executionGuard: legacyExecutionGuard,
|
||||
...nextState
|
||||
} = mergedState;
|
||||
void legacyCurrentNode;
|
||||
void legacyExecutionGuard;
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
completedNodes: dedupeNodes((nextState.completedNodes ?? []).filter(isValidNode)),
|
||||
draft: nextState.draft ?? {},
|
||||
executionGuard:
|
||||
nextState.executionGuard?.readToken && nextState.executionGuard?.issuedAt
|
||||
? nextState.executionGuard
|
||||
: undefined,
|
||||
questionSurface:
|
||||
nextState.questionSurface?.node &&
|
||||
getActiveNode(nextState) === nextState.questionSurface.node
|
||||
@@ -781,64 +744,9 @@ export class OnboardingService {
|
||||
};
|
||||
};
|
||||
|
||||
private issueReadToken = async (state: UserAgentOnboarding) => {
|
||||
const readToken = randomUUID();
|
||||
const nextState = await this.saveState({
|
||||
...state,
|
||||
executionGuard: {
|
||||
issuedAt: new Date().toISOString(),
|
||||
readToken,
|
||||
},
|
||||
});
|
||||
|
||||
return { readToken, state: nextState };
|
||||
};
|
||||
|
||||
private consumeReadToken = async (readToken?: string) => {
|
||||
const state = await this.ensurePersistedState();
|
||||
|
||||
if (
|
||||
!readToken ||
|
||||
!state.executionGuard?.readToken ||
|
||||
state.executionGuard.readToken !== readToken
|
||||
) {
|
||||
return {
|
||||
error: {
|
||||
code: 'STATE_READ_REQUIRED' as const,
|
||||
message:
|
||||
'Call getOnboardingState immediately before any other onboarding tool. The read token is missing, stale, or already used.',
|
||||
},
|
||||
instruction:
|
||||
'Call getOnboardingState immediately before any other onboarding tool. Then use the latest control.readToken in the next tool call.',
|
||||
state,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { executionGuard: _executionGuard, ...restState } = state;
|
||||
void _executionGuard;
|
||||
|
||||
await this.saveState(restState);
|
||||
|
||||
return {
|
||||
state: restState,
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
getState = async (options?: {
|
||||
issueReadToken?: boolean;
|
||||
}): Promise<UserAgentOnboardingContext> => {
|
||||
getState = async (): Promise<UserAgentOnboardingContext> => {
|
||||
const userState = await this.getUserState();
|
||||
let state = this.ensureState(userState.agentOnboarding);
|
||||
let readToken: string | undefined;
|
||||
|
||||
if (options?.issueReadToken && !state.finishedAt) {
|
||||
const issued = await this.issueReadToken(state);
|
||||
readToken = issued.readToken;
|
||||
state = issued.state;
|
||||
}
|
||||
|
||||
const state = this.ensureState(userState.agentOnboarding);
|
||||
const committed = {
|
||||
agentIdentity: state.agentIdentity,
|
||||
defaultModel: userState.settings.defaultAgent?.config
|
||||
@@ -882,11 +790,10 @@ export class OnboardingService {
|
||||
activeNode,
|
||||
activeNodeDraftState,
|
||||
currentQuestion,
|
||||
readToken,
|
||||
}),
|
||||
currentQuestion,
|
||||
draft,
|
||||
finishedAt: state.finishedAt,
|
||||
currentQuestion,
|
||||
topicId: state.activeTopicId,
|
||||
version: state.version,
|
||||
};
|
||||
@@ -895,24 +802,7 @@ export class OnboardingService {
|
||||
askQuestion = async (params: {
|
||||
node: UserAgentOnboardingNode;
|
||||
question: UserAgentOnboardingQuestionDraft;
|
||||
readToken: string;
|
||||
}): Promise<AskQuestionResult> => {
|
||||
const readTokenResult = await this.consumeReadToken(params.readToken);
|
||||
|
||||
if (!readTokenResult.success) {
|
||||
const nextContext = await this.getState();
|
||||
const error = readTokenResult.error!;
|
||||
|
||||
return {
|
||||
activeNode: nextContext.activeNode,
|
||||
content: error.message,
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
instruction: readTokenResult.instruction,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const context = await this.getState();
|
||||
const activeNode = context.activeNode;
|
||||
|
||||
@@ -925,14 +815,11 @@ export class OnboardingService {
|
||||
}
|
||||
|
||||
if (params.node !== activeNode) {
|
||||
const instruction = getNodeRecoveryInstruction(activeNode);
|
||||
|
||||
return {
|
||||
activeNode,
|
||||
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}". ${instruction}`,
|
||||
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}".`,
|
||||
control: context.control,
|
||||
currentQuestion: context.currentQuestion,
|
||||
instruction,
|
||||
mismatch: true,
|
||||
requestedNode: params.node,
|
||||
success: false,
|
||||
@@ -964,29 +851,8 @@ export class OnboardingService {
|
||||
};
|
||||
|
||||
saveAnswer = async (params: {
|
||||
readToken: string;
|
||||
updates: Array<Pick<UserAgentOnboardingUpdate, 'node'> & { patch: OnboardingPatchInput }>;
|
||||
}): Promise<ProposePatchResult> => {
|
||||
const readTokenResult = await this.consumeReadToken(params.readToken);
|
||||
|
||||
if (!readTokenResult.success) {
|
||||
const nextContext = await this.getState();
|
||||
const error = readTokenResult.error!;
|
||||
|
||||
return {
|
||||
activeNode: nextContext.activeNode,
|
||||
activeNodeDraftState: nextContext.activeNodeDraftState,
|
||||
content: error.message,
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
draft: nextContext.draft,
|
||||
error,
|
||||
instruction: readTokenResult.instruction,
|
||||
nextAction: 'ask',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const updates = [...params.updates].sort((left, right) => {
|
||||
return getNodeIndex(left.node) - getNodeIndex(right.node);
|
||||
});
|
||||
@@ -1001,41 +867,37 @@ export class OnboardingService {
|
||||
latestResult = result;
|
||||
contentParts.push(result.content);
|
||||
|
||||
if (result.success) {
|
||||
processedNodes.push(update.node);
|
||||
if (!result.success) {
|
||||
const nextContext = await this.getState();
|
||||
|
||||
if (result.activeNodeDraftState?.status === 'partial') {
|
||||
const nextContext = await this.getState();
|
||||
|
||||
return {
|
||||
...result,
|
||||
content: contentParts.join('\n'),
|
||||
activeNode: nextContext.activeNode,
|
||||
activeNodeDraftState: nextContext.activeNodeDraftState,
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
draft: nextContext.draft,
|
||||
processedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
continue;
|
||||
return {
|
||||
...result,
|
||||
activeNode: nextContext.activeNode,
|
||||
activeNodeDraftState: nextContext.activeNodeDraftState,
|
||||
content: contentParts.join('\n'),
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
draft: nextContext.draft,
|
||||
processedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.mismatch) continue;
|
||||
processedNodes.push(update.node);
|
||||
|
||||
const nextContext = await this.getState();
|
||||
if (result.activeNodeDraftState?.status === 'partial') {
|
||||
const nextContext = await this.getState();
|
||||
|
||||
return {
|
||||
...result,
|
||||
content: contentParts.join('\n'),
|
||||
activeNode: nextContext.activeNode,
|
||||
activeNodeDraftState: nextContext.activeNodeDraftState,
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
draft: nextContext.draft,
|
||||
processedNodes,
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
activeNode: nextContext.activeNode,
|
||||
activeNodeDraftState: nextContext.activeNodeDraftState,
|
||||
content: contentParts.join('\n'),
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
draft: nextContext.draft,
|
||||
processedNodes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const nextContext = await this.getState();
|
||||
@@ -1047,9 +909,9 @@ export class OnboardingService {
|
||||
nextAction: 'ask' as const,
|
||||
success: false,
|
||||
}),
|
||||
content: contentParts.join('\n'),
|
||||
activeNode: nextContext.activeNode,
|
||||
activeNodeDraftState: nextContext.activeNodeDraftState,
|
||||
content: contentParts.join('\n'),
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
draft: nextContext.draft,
|
||||
@@ -1077,51 +939,14 @@ export class OnboardingService {
|
||||
}
|
||||
|
||||
if (params.node !== activeNode) {
|
||||
const activeNodeIndex = getNodeIndex(activeNode);
|
||||
const requestedNodeIndex = getNodeIndex(params.node);
|
||||
const recoverableDraft =
|
||||
requestedNodeIndex > activeNodeIndex
|
||||
? extractDraftForNode(params.node, params.patch)
|
||||
: undefined;
|
||||
|
||||
if (recoverableDraft) {
|
||||
const draft = mergeDraftForNode(
|
||||
context.draft,
|
||||
params.node,
|
||||
getDraftValueForNode(recoverableDraft, params.node),
|
||||
);
|
||||
const instruction = getNodeRecoveryInstruction(activeNode);
|
||||
|
||||
await this.saveState({ ...(await this.ensurePersistedState()), draft });
|
||||
|
||||
return {
|
||||
activeNode,
|
||||
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}". I saved the later-step draft for "${params.node}", but do not advance yet. ${instruction}`,
|
||||
draft,
|
||||
error: {
|
||||
code: 'NODE_MISMATCH',
|
||||
message: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}".`,
|
||||
},
|
||||
instruction,
|
||||
mismatch: true,
|
||||
nextAction: 'ask',
|
||||
requestedNode: params.node,
|
||||
savedDraftFields: Object.keys(recoverableDraft) as (keyof UserAgentOnboardingDraft)[],
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const instruction = getNodeRecoveryInstruction(activeNode);
|
||||
|
||||
return {
|
||||
activeNode,
|
||||
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}". ${instruction}`,
|
||||
content: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}".`,
|
||||
draft: context.draft,
|
||||
error: {
|
||||
code: 'NODE_MISMATCH',
|
||||
message: `Node mismatch: active onboarding step is "${activeNode}", but you called "${params.node}".`,
|
||||
},
|
||||
instruction,
|
||||
mismatch: true,
|
||||
nextAction: 'ask',
|
||||
requestedNode: params.node,
|
||||
@@ -1182,10 +1007,9 @@ export class OnboardingService {
|
||||
committedValue: getDraftValueForNode(draft, activeNode),
|
||||
content: commitResult.content,
|
||||
control: commitResult.control,
|
||||
currentQuestion: commitResult.currentQuestion,
|
||||
draft: {},
|
||||
instruction: commitResult.instruction,
|
||||
nextAction: 'ask',
|
||||
requiresReadBeforeNextTool: commitResult.requiresReadBeforeNextTool,
|
||||
success: commitResult.success,
|
||||
};
|
||||
}
|
||||
@@ -1213,7 +1037,7 @@ export class OnboardingService {
|
||||
private commitActiveStep = async (
|
||||
state: UserAgentOnboarding,
|
||||
activeNode: UserAgentOnboardingNode,
|
||||
) => {
|
||||
): Promise<CommitStepResult> => {
|
||||
const draft = state.draft ?? {};
|
||||
|
||||
switch (activeNode) {
|
||||
@@ -1231,7 +1055,6 @@ export class OnboardingService {
|
||||
? state.questionSurface.question
|
||||
: undefined,
|
||||
}),
|
||||
nextNode: activeNode,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -1253,7 +1076,6 @@ export class OnboardingService {
|
||||
? state.questionSurface.question
|
||||
: undefined,
|
||||
}),
|
||||
nextNode: activeNode,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -1283,7 +1105,6 @@ export class OnboardingService {
|
||||
? state.questionSurface.question
|
||||
: undefined,
|
||||
}),
|
||||
nextNode: activeNode,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -1308,7 +1129,6 @@ export class OnboardingService {
|
||||
? state.questionSurface.question
|
||||
: undefined,
|
||||
}),
|
||||
nextNode: activeNode,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -1344,7 +1164,6 @@ export class OnboardingService {
|
||||
? state.questionSurface.question
|
||||
: undefined,
|
||||
}),
|
||||
nextNode: activeNode,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -1367,7 +1186,6 @@ export class OnboardingService {
|
||||
? state.questionSurface.question
|
||||
: undefined,
|
||||
}),
|
||||
nextNode: activeNode,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -1383,17 +1201,30 @@ export class OnboardingService {
|
||||
case 'proSettings': {
|
||||
const defaultModel = normalizeDefaultModel(draft.defaultModel);
|
||||
|
||||
if (defaultModel) {
|
||||
const currentSettings = await this.userModel.getUserSettings();
|
||||
await this.userModel.updateSetting({
|
||||
defaultAgent: merge(currentSettings?.defaultAgent || {}, {
|
||||
config: {
|
||||
model: defaultModel.model,
|
||||
provider: defaultModel.provider,
|
||||
},
|
||||
if (!defaultModel) {
|
||||
return {
|
||||
content: 'Default model has not been captured yet.',
|
||||
control: buildOnboardingControl({
|
||||
activeNode,
|
||||
activeNodeDraftState: getNodeDraftState(activeNode, draft),
|
||||
currentQuestion:
|
||||
state.questionSurface?.node === activeNode
|
||||
? state.questionSurface.question
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const currentSettings = await this.userModel.getUserSettings();
|
||||
await this.userModel.updateSetting({
|
||||
defaultAgent: merge(currentSettings?.defaultAgent || {}, {
|
||||
config: {
|
||||
model: defaultModel.model,
|
||||
provider: defaultModel.provider,
|
||||
},
|
||||
}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'summary': {
|
||||
@@ -1401,13 +1232,11 @@ export class OnboardingService {
|
||||
content: 'Use finishOnboarding from the summary step.',
|
||||
control: buildOnboardingControl({
|
||||
activeNode,
|
||||
activeNodeDraftState: undefined,
|
||||
currentQuestion:
|
||||
state.questionSurface?.node === activeNode
|
||||
? state.questionSurface.question
|
||||
: undefined,
|
||||
}),
|
||||
nextNode: activeNode,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -1437,38 +1266,14 @@ export class OnboardingService {
|
||||
content: nextNode
|
||||
? `Committed step "${activeNode}". Continue with "${nextNode}".`
|
||||
: `Committed step "${activeNode}".`,
|
||||
activeNodeDraftState: nextContext.activeNodeDraftState,
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
instruction:
|
||||
nextNode && !nextContext.currentQuestion
|
||||
? `State advanced to "${nextNode}". Call getOnboardingState again before any other onboarding tool. If the next step still has no current question, call askUserQuestion for "${nextNode}" before replying.`
|
||||
: undefined,
|
||||
nextNode: nextNode ?? activeNode,
|
||||
requiresReadBeforeNextTool: true,
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
completeCurrentStep = async (node: UserAgentOnboardingNode, readToken: string) => {
|
||||
const readTokenResult = await this.consumeReadToken(readToken);
|
||||
|
||||
if (!readTokenResult.success) {
|
||||
const nextContext = await this.getState();
|
||||
const error = readTokenResult.error!;
|
||||
|
||||
return {
|
||||
activeNode: nextContext.activeNode,
|
||||
activeNodeDraftState: nextContext.activeNodeDraftState,
|
||||
content: error.message,
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
instruction: readTokenResult.instruction,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const state = readTokenResult.state;
|
||||
completeCurrentStep = async (node: UserAgentOnboardingNode) => {
|
||||
const state = await this.ensurePersistedState();
|
||||
const activeNode = getActiveNode(state);
|
||||
|
||||
if (!activeNode) {
|
||||
@@ -1485,16 +1290,32 @@ export class OnboardingService {
|
||||
|
||||
if (node !== activeNode) {
|
||||
return {
|
||||
activeNode,
|
||||
activeNodeDraftState: getNodeDraftState(activeNode, state.draft ?? {}),
|
||||
content: `Active onboarding step is "${activeNode}", not "${node}".`,
|
||||
control: buildOnboardingControl({
|
||||
activeNode,
|
||||
activeNodeDraftState: getNodeDraftState(activeNode, state.draft ?? {}),
|
||||
currentQuestion:
|
||||
state.questionSurface && state.questionSurface.node === activeNode
|
||||
? state.questionSurface.question
|
||||
: undefined,
|
||||
state.questionSurface?.node === activeNode ? state.questionSurface.question : undefined,
|
||||
}),
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const draftState = getNodeDraftState(activeNode, state.draft ?? {});
|
||||
|
||||
if (activeNode !== 'summary' && draftState?.status !== 'complete') {
|
||||
return {
|
||||
activeNode,
|
||||
activeNodeDraftState: draftState,
|
||||
content: `Active onboarding step "${activeNode}" is still incomplete.`,
|
||||
control: buildOnboardingControl({
|
||||
activeNode,
|
||||
activeNodeDraftState: draftState,
|
||||
currentQuestion:
|
||||
state.questionSurface?.node === activeNode ? state.questionSurface.question : undefined,
|
||||
}),
|
||||
nextNode: activeNode,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -1502,36 +1323,19 @@ export class OnboardingService {
|
||||
return this.commitActiveStep(state, activeNode);
|
||||
};
|
||||
|
||||
returnToOnboarding = async (readToken: string, reason?: string) => {
|
||||
const readTokenResult = await this.consumeReadToken(readToken);
|
||||
|
||||
if (!readTokenResult.success) {
|
||||
const nextContext = await this.getState();
|
||||
const error = readTokenResult.error!;
|
||||
|
||||
return {
|
||||
activeNode: nextContext.activeNode,
|
||||
content: error.message,
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
instruction: readTokenResult.instruction,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const state = readTokenResult.state;
|
||||
returnToOnboarding = async (reason?: string) => {
|
||||
const state = await this.ensurePersistedState();
|
||||
const activeNode = getActiveNode(state);
|
||||
const draft = state.draft ?? {};
|
||||
const questionSurface = state.questionSurface;
|
||||
const currentQuestion =
|
||||
state.questionSurface && state.questionSurface.node === activeNode
|
||||
? state.questionSurface.question
|
||||
: undefined;
|
||||
questionSurface && questionSurface.node === activeNode ? questionSurface.question : undefined;
|
||||
|
||||
return {
|
||||
activeNode,
|
||||
content: reason
|
||||
? `Stay on onboarding. Off-topic reason: ${reason}`
|
||||
: 'Stay on onboarding and continue with the current question.',
|
||||
activeNode,
|
||||
control: buildOnboardingControl({
|
||||
activeNode,
|
||||
activeNodeDraftState: getNodeDraftState(activeNode, draft),
|
||||
@@ -1542,35 +1346,21 @@ export class OnboardingService {
|
||||
};
|
||||
};
|
||||
|
||||
finishOnboarding = async (readToken: string) => {
|
||||
const readTokenResult = await this.consumeReadToken(readToken);
|
||||
|
||||
if (!readTokenResult.success) {
|
||||
const nextContext = await this.getState();
|
||||
const error = readTokenResult.error!;
|
||||
|
||||
return {
|
||||
activeNode: nextContext.activeNode,
|
||||
content: error.message,
|
||||
control: nextContext.control,
|
||||
currentQuestion: nextContext.currentQuestion,
|
||||
instruction: readTokenResult.instruction,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const state = readTokenResult.state;
|
||||
finishOnboarding = async () => {
|
||||
const state = await this.ensurePersistedState();
|
||||
const activeNode = getActiveNode(state);
|
||||
|
||||
if (activeNode !== 'summary') {
|
||||
const questionSurface = state.questionSurface;
|
||||
|
||||
return {
|
||||
content: `Active onboarding step is "${activeNode ?? 'completed'}". Finish is only allowed in "summary".`,
|
||||
control: buildOnboardingControl({
|
||||
activeNode,
|
||||
activeNodeDraftState: getNodeDraftState(activeNode, state.draft ?? {}),
|
||||
currentQuestion:
|
||||
state.questionSurface && state.questionSurface.node === activeNode
|
||||
? state.questionSurface.question
|
||||
questionSurface && questionSurface.node === activeNode
|
||||
? questionSurface.question
|
||||
: undefined,
|
||||
}),
|
||||
success: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ export const webOnboardingRuntime: ServerRuntimeRegistration = {
|
||||
proxy[api.name] = async (args: Record<string, unknown>) => {
|
||||
switch (api.name) {
|
||||
case 'getOnboardingState': {
|
||||
const result = await service.getState({ issueReadToken: true });
|
||||
const result = await service.getState();
|
||||
|
||||
return { content: JSON.stringify(result, null, 2), state: result, success: true };
|
||||
}
|
||||
@@ -36,23 +36,17 @@ export const webOnboardingRuntime: ServerRuntimeRegistration = {
|
||||
return createWebOnboardingToolResult(result);
|
||||
}
|
||||
case 'completeCurrentStep': {
|
||||
const result = await service.completeCurrentStep(
|
||||
args.node as any,
|
||||
args.readToken as string,
|
||||
);
|
||||
const result = await service.completeCurrentStep(args.node as any);
|
||||
|
||||
return createWebOnboardingToolResult(result);
|
||||
}
|
||||
case 'returnToOnboarding': {
|
||||
const result = await service.returnToOnboarding(
|
||||
args.readToken as string,
|
||||
args.reason as string | undefined,
|
||||
);
|
||||
const result = await service.returnToOnboarding(args.reason as string | undefined);
|
||||
|
||||
return createWebOnboardingToolResult(result);
|
||||
}
|
||||
case 'finishOnboarding': {
|
||||
const result = await service.finishOnboarding(args.readToken as string);
|
||||
const result = await service.finishOnboarding();
|
||||
|
||||
return createWebOnboardingToolResult(result);
|
||||
}
|
||||
|
||||
@@ -45,10 +45,7 @@ export class UserService {
|
||||
return lambdaClient.user.getOnboardingState.query();
|
||||
};
|
||||
|
||||
saveOnboardingAnswer = async (params: {
|
||||
readToken: string;
|
||||
updates: UserAgentOnboardingUpdate[];
|
||||
}) => {
|
||||
saveOnboardingAnswer = async (params: { updates: UserAgentOnboardingUpdate[] }) => {
|
||||
return lambdaClient.user.saveOnboardingAnswer.mutate(
|
||||
params as Parameters<typeof lambdaClient.user.saveOnboardingAnswer.mutate>[0],
|
||||
);
|
||||
@@ -57,21 +54,20 @@ export class UserService {
|
||||
askOnboardingQuestion = async (params: {
|
||||
node: UserAgentOnboardingNode;
|
||||
question: UserAgentOnboardingQuestionDraft;
|
||||
readToken: string;
|
||||
}) => {
|
||||
return lambdaClient.user.askOnboardingQuestion.mutate(params);
|
||||
};
|
||||
|
||||
completeOnboardingStep = async (node: UserAgentOnboardingNode, readToken: string) => {
|
||||
return lambdaClient.user.completeOnboardingStep.mutate({ node, readToken });
|
||||
completeOnboardingStep = async (node: UserAgentOnboardingNode) => {
|
||||
return lambdaClient.user.completeOnboardingStep.mutate({ node });
|
||||
};
|
||||
|
||||
returnToOnboarding = async (readToken: string, reason?: string) => {
|
||||
return lambdaClient.user.returnToOnboarding.mutate({ readToken, reason });
|
||||
returnToOnboarding = async (reason?: string) => {
|
||||
return lambdaClient.user.returnToOnboarding.mutate({ reason });
|
||||
};
|
||||
|
||||
finishOnboarding = async (readToken: string) => {
|
||||
return lambdaClient.user.finishOnboarding.mutate({ readToken });
|
||||
finishOnboarding = async () => {
|
||||
return lambdaClient.user.finishOnboarding.mutate({});
|
||||
};
|
||||
|
||||
makeUserOnboarded = async () => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { userService } from '@/services/user';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
import { webOnboardingExecutor } from './lobe-web-onboarding';
|
||||
|
||||
vi.mock('@/services/user', () => ({
|
||||
userService: {
|
||||
askOnboardingQuestion: vi.fn(),
|
||||
completeOnboardingStep: vi.fn(),
|
||||
finishOnboarding: vi.fn(),
|
||||
getOnboardingState: vi.fn(),
|
||||
returnToOnboarding: vi.fn(),
|
||||
saveOnboardingAnswer: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/user', () => ({
|
||||
useUserStore: {
|
||||
getState: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('webOnboardingExecutor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useUserStore.getState).mockReturnValue({
|
||||
refreshUserState: vi.fn().mockResolvedValue(undefined),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('refreshes user onboarding state after askUserQuestion', async () => {
|
||||
const refreshUserState = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useUserStore.getState).mockReturnValue({
|
||||
refreshUserState,
|
||||
} as any);
|
||||
vi.mocked(userService.askOnboardingQuestion).mockResolvedValue({
|
||||
content: 'Saved the current question for "agentIdentity".',
|
||||
currentQuestion: {
|
||||
id: 'agent_identity_001',
|
||||
mode: 'button_group',
|
||||
node: 'agentIdentity',
|
||||
prompt: '贾维斯的气质应该是什么样的?',
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
|
||||
await webOnboardingExecutor.askUserQuestion(
|
||||
{
|
||||
node: 'agentIdentity',
|
||||
question: {
|
||||
id: 'agent_identity_001',
|
||||
mode: 'button_group',
|
||||
prompt: '贾维斯的气质应该是什么样的?',
|
||||
},
|
||||
},
|
||||
{} as any,
|
||||
);
|
||||
|
||||
expect(refreshUserState).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,18 @@ import { type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types
|
||||
import { BaseExecutor } from '@lobechat/types';
|
||||
|
||||
import { userService } from '@/services/user';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
import { createWebOnboardingToolResult } from '../../../../../utils/webOnboardingToolResult';
|
||||
|
||||
const syncUserOnboardingState = async () => {
|
||||
try {
|
||||
await useUserStore.getState().refreshUserState();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
|
||||
readonly identifier = WebOnboardingIdentifier;
|
||||
protected readonly apiEnum = WebOnboardingApiName;
|
||||
@@ -25,12 +34,12 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
|
||||
|
||||
saveAnswer = async (
|
||||
params: {
|
||||
readToken: string;
|
||||
updates: Parameters<typeof userService.saveOnboardingAnswer>[0]['updates'];
|
||||
},
|
||||
_ctx: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
const result = await userService.saveOnboardingAnswer(params);
|
||||
await syncUserOnboardingState();
|
||||
|
||||
return createWebOnboardingToolResult(result);
|
||||
};
|
||||
@@ -39,11 +48,11 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
|
||||
params: {
|
||||
node: Parameters<typeof userService.askOnboardingQuestion>[0]['node'];
|
||||
question: Parameters<typeof userService.askOnboardingQuestion>[0]['question'];
|
||||
readToken: Parameters<typeof userService.askOnboardingQuestion>[0]['readToken'];
|
||||
},
|
||||
_ctx: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
const result = await userService.askOnboardingQuestion(params);
|
||||
await syncUserOnboardingState();
|
||||
|
||||
return {
|
||||
content: result.content,
|
||||
@@ -55,29 +64,28 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
|
||||
completeCurrentStep = async (
|
||||
params: {
|
||||
node: Parameters<typeof userService.completeOnboardingStep>[0];
|
||||
readToken: Parameters<typeof userService.completeOnboardingStep>[1];
|
||||
},
|
||||
_ctx: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
const result = await userService.completeOnboardingStep(params.node, params.readToken);
|
||||
const result = await userService.completeOnboardingStep(params.node);
|
||||
await syncUserOnboardingState();
|
||||
|
||||
return createWebOnboardingToolResult(result);
|
||||
};
|
||||
|
||||
returnToOnboarding = async (
|
||||
params: { readToken: string; reason?: string },
|
||||
params: { reason?: string },
|
||||
_ctx: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
const result = await userService.returnToOnboarding(params.readToken, params.reason);
|
||||
const result = await userService.returnToOnboarding(params.reason);
|
||||
await syncUserOnboardingState();
|
||||
|
||||
return createWebOnboardingToolResult(result);
|
||||
};
|
||||
|
||||
finishOnboarding = async (
|
||||
params: { readToken: string },
|
||||
_ctx: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
const result = await userService.finishOnboarding(params.readToken);
|
||||
finishOnboarding = async (_params: Record<string, never>, _ctx: BuiltinToolContext) => {
|
||||
const result = await userService.finishOnboarding();
|
||||
await syncUserOnboardingState();
|
||||
|
||||
return createWebOnboardingToolResult(result);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user