mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ feat(onboarding): inline response language step in agent conversation
- Add ResponseLanguageInlineStep and wire into Conversation flow - Extend agent onboarding context and update ResponseLanguageStep route - Add tests and onboarding agent document design spec Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
# Onboarding Agent Identity → Inbox Agent Documents
|
||||
|
||||
**Date:** 2026-03-24
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The onboarding flow collects agent identity and user profile data but stores it only in `user.agentOnboarding.agentIdentity`. This data should instead be written directly to the inbox (lobeAi) agent's `agent_document` table as `IDENTITY.md` and `SOUL.md`, making it immediately available as durable agent context.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
| ------------------------ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| Write target | Both IDENTITY.md and SOUL.md | Identity data fits IDENTITY.md; profile summaries enrich SOUL.md |
|
||||
| Write mechanism | Server-side in `commitActiveStep` | Secure, controlled; no cross-agent tool exposure needed |
|
||||
| SOUL.md content strategy | Fixed template + profile section concatenation | Deterministic, no LLM overhead; onboarding AI summaries already high quality |
|
||||
| AI polishing | None | Each node's `summary` field is already AI-generated during onboarding |
|
||||
| SOUL.md regeneration | Always from CLAW template constant + profile data | Deterministic; agent self-edits during onboarding are not preserved (acceptable since onboarding is a one-time setup) |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
commitActiveStep('agentIdentity')
|
||||
→ upsert inbox IDENTITY.md (full content from name/vibe/emoji/nature)
|
||||
→ upsert inbox SOUL.md (Core Truths + Boundaries + Vibe + Continuity, no profile sections yet)
|
||||
|
||||
commitActiveStep('userIdentity')
|
||||
→ upsert inbox SOUL.md (fixed sections + "About My Human")
|
||||
|
||||
commitActiveStep('workStyle')
|
||||
→ upsert inbox SOUL.md (fixed sections + accumulated profile sections + "How We Work Together")
|
||||
|
||||
commitActiveStep('workContext')
|
||||
→ upsert inbox SOUL.md (fixed sections + accumulated profile sections + "Current Context")
|
||||
|
||||
commitActiveStep('painPoints')
|
||||
→ upsert inbox SOUL.md (fixed sections + accumulated profile sections + "Where I Can Help Most")
|
||||
|
||||
commitActiveStep('responseLanguage')
|
||||
→ no document write (saves to user.settings.general.responseLanguage)
|
||||
|
||||
commitActiveStep('summary')
|
||||
→ no document write (terminal node)
|
||||
```
|
||||
|
||||
`buildSoulDocument` always regenerates from the CLAW `SOUL_DOCUMENT.content` constant plus all profile sections present in `state.profile`. It does NOT read/parse existing SOUL.md from the database. This is deterministic and avoids fragile content parsing. Tradeoff: any agent self-edits to SOUL.md during onboarding would be overwritten. This is acceptable since onboarding is a one-time initial setup.
|
||||
|
||||
### IDENTITY.md Template
|
||||
|
||||
Written once during `agentIdentity` node commit. Uses CLAW template's load position (BEFORE_SYSTEM, priority 0).
|
||||
|
||||
```markdown
|
||||
# IDENTITY.md - Who Am I?
|
||||
|
||||
- **Name:** {agentIdentity.name}
|
||||
- **Creature:** {agentIdentity.nature}
|
||||
- **Vibe:** {agentIdentity.vibe}
|
||||
- **Emoji:** {agentIdentity.emoji}
|
||||
```
|
||||
|
||||
### SOUL.md Structure
|
||||
|
||||
Retains existing CLAW SOUL.md fixed sections (from `SOUL_DOCUMENT.content` constant). Profile sections appended progressively as nodes complete. Uses SYSTEM_APPEND, priority 1.
|
||||
|
||||
```markdown
|
||||
# SOUL.md - Who You Are
|
||||
|
||||
_You're not a chatbot. You're becoming someone._
|
||||
|
||||
## Core Truths
|
||||
|
||||
(existing fixed content from CLAW template constant)
|
||||
|
||||
## Boundaries
|
||||
|
||||
(existing fixed content)
|
||||
|
||||
## Vibe
|
||||
|
||||
(existing fixed content)
|
||||
|
||||
## Continuity
|
||||
|
||||
(existing fixed content)
|
||||
|
||||
---
|
||||
|
||||
## About My Human
|
||||
|
||||
{userIdentity.summary}
|
||||
|
||||
## How We Work Together
|
||||
|
||||
{workStyle.summary}
|
||||
|
||||
## Current Context
|
||||
|
||||
{workContext.summary}
|
||||
|
||||
- **Active Projects:** {workContext.activeProjects joined}
|
||||
- **Interests:** {workContext.interests joined}
|
||||
- **Tools:** {workContext.tools joined}
|
||||
|
||||
## Where I Can Help Most
|
||||
|
||||
{painPoints.summary}
|
||||
```
|
||||
|
||||
Sections only appear after their corresponding node is committed. Empty/missing summaries are omitted.
|
||||
|
||||
### `buildSoulDocument` Pseudocode
|
||||
|
||||
```typescript
|
||||
function buildSoulDocument(state: UserAgentOnboarding): string {
|
||||
// Start with fixed CLAW template content
|
||||
let content = SOUL_DOCUMENT.content;
|
||||
|
||||
const profile = state.profile;
|
||||
if (!profile) return content;
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
if (profile.identity?.summary) {
|
||||
sections.push(`## About My Human\n\n${profile.identity.summary}`);
|
||||
}
|
||||
|
||||
if (profile.workStyle?.summary) {
|
||||
sections.push(`## How We Work Together\n\n${profile.workStyle.summary}`);
|
||||
}
|
||||
|
||||
if (profile.workContext?.summary) {
|
||||
let section = `## Current Context\n\n${profile.workContext.summary}`;
|
||||
const lists = [];
|
||||
if (profile.workContext.activeProjects?.length)
|
||||
lists.push(`- **Active Projects:** ${profile.workContext.activeProjects.join(', ')}`);
|
||||
if (profile.workContext.interests?.length)
|
||||
lists.push(`- **Interests:** ${profile.workContext.interests.join(', ')}`);
|
||||
if (profile.workContext.tools?.length)
|
||||
lists.push(`- **Tools:** ${profile.workContext.tools.join(', ')}`);
|
||||
if (lists.length) section += '\n\n' + lists.join('\n');
|
||||
sections.push(section);
|
||||
}
|
||||
|
||||
if (profile.painPoints?.summary) {
|
||||
sections.push(`## Where I Can Help Most\n\n${profile.painPoints.summary}`);
|
||||
}
|
||||
|
||||
if (sections.length) {
|
||||
content += '\n\n---\n\n' + sections.join('\n\n');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Changes
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. **`src/server/services/onboarding/index.ts`**
|
||||
- Inject `AgentDocumentsService` dependency (or instantiate via `serverDB` + `userId`)
|
||||
- In `commitActiveStep`, after each relevant node commit, call document upsert
|
||||
- Remove write to `state.agentIdentity` (replaced by IDENTITY.md)
|
||||
- Keep `state.agentIdentity` readable for onboarding UI display (write to both state AND document, or read back from document)
|
||||
- Add helpers: `getInboxAgentId`, `buildIdentityDocument`, `buildSoulDocument`
|
||||
- Both IDENTITY.md and SOUL.md upserts in `agentIdentity` node should be in the same logical operation
|
||||
|
||||
2. **`packages/database/src/models/agentDocuments/agentDocument.ts`**
|
||||
- Verify `upsert()` by filename works correctly when the document already exists from template initialization (it should — `upsert` merges metadata and preserves loadPosition/loadRules when not explicitly provided)
|
||||
|
||||
### New Helpers (in onboarding service)
|
||||
|
||||
- `getInboxAgentId(): Promise<string>` — query `AgentModel` with inbox agent slug (`BUILTIN_AGENT_SLUGS.inbox` = `'inbox'`). If `AgentService.getBuiltinAgent()` does not return a raw ID, add `agentModel.getBuiltinAgentId(slug)` query.
|
||||
- `buildIdentityDocument(agentIdentity): string` — render IDENTITY.md content from name/vibe/emoji/nature
|
||||
- `buildSoulDocument(state): string` — render SOUL.md from CLAW template constant + accumulated profile sections (see pseudocode above)
|
||||
|
||||
### Unchanged
|
||||
|
||||
- Onboarding state machine, node flow, draft/questionSurface mechanics
|
||||
- `state.profile` still written (used by onboarding UI for display)
|
||||
- CLAW template definitions (templates remain as defaults for non-onboarded agents)
|
||||
- Tool layer (`builtin-tool-agent-documents`) — no changes needed
|
||||
- `AGENTS.md` document — untouched by onboarding flow
|
||||
|
||||
### Inbox Agent ID Resolution
|
||||
|
||||
Query via `AgentModel` using the inbox agent slug `BUILTIN_AGENT_SLUGS.inbox` (value: `'inbox'`). The existing `AgentService.getBuiltinAgent(slug)` returns a merged config object. If it does not expose the raw agent `id`, add a lightweight `agentModel.findBySlug(slug, userId)` query that returns just the ID. The service already has `serverDB` and `userId`.
|
||||
|
||||
### Upsert Load Policy
|
||||
|
||||
When calling `upsert()` for IDENTITY.md and SOUL.md, do NOT pass `loadPosition` or `loadRules` explicitly. The `upsert` merge logic preserves existing values when parameters are `undefined` (verified: `loadPosition || existingContext.position` in update path). This ensures template-initialized load policies are preserved.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Re-onboarding (reset):** `reset()` should call `agentDocumentModel.deleteByTemplate(inboxAgentId, 'claw')` then `initializeFromTemplate(inboxAgentId, 'claw')` to revert documents to defaults. Non-template documents (agent self-created) survive reset.
|
||||
- **Inbox agent not yet initialized:** Check `agentDocumentModel.hasByAgent(inboxAgentId)` first. If false, call `initializeFromTemplate('claw')` before upserting. The subsequent upsert will overwrite the template defaults for IDENTITY.md/SOUL.md — this double-write is intentional and acceptable (idempotent upsert).
|
||||
- **Partial onboarding:** User may abandon mid-flow. IDENTITY.md will exist (written at first node), SOUL.md will have partial profile sections. Acceptable — content reflects what was collected.
|
||||
- **`state.agentIdentity` removal:** `state.agentIdentity` is separate from `state.profile`. If onboarding UI reads `state.agentIdentity` for display (e.g., showing agent name/emoji in summary screen), either: (a) keep writing to `state.agentIdentity` as well (dual write), or (b) update UI to read from the document. Recommend (a) for minimal UI disruption during this change.
|
||||
@@ -2,6 +2,8 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type * as EnvModule from '@/utils/env';
|
||||
|
||||
import AgentOnboardingConversation from './Conversation';
|
||||
|
||||
const { chatInputSpy, mockState } = vi.hoisted(() => ({
|
||||
@@ -11,7 +13,14 @@ const { chatInputSpy, mockState } = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/env', () => ({ isDev: false }));
|
||||
vi.mock('@/utils/env', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof EnvModule>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
isDev: false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/features/Conversation', () => ({
|
||||
ChatInput: (props: Record<string, unknown>) => {
|
||||
@@ -46,6 +55,20 @@ vi.mock('@/features/Conversation', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/Conversation/hooks/useAgentMeta', () => ({
|
||||
useAgentMeta: () => ({
|
||||
avatar: 'assistant-avatar',
|
||||
backgroundColor: '#000',
|
||||
title: 'Onboarding Agent',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./QuestionRenderer', () => ({
|
||||
default: ({
|
||||
currentQuestion,
|
||||
@@ -65,6 +88,14 @@ vi.mock('./QuestionRenderer', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ResponseLanguageInlineStep', () => ({
|
||||
default: ({ onDismissNode }: { onDismissNode?: (node: string) => void }) => (
|
||||
<div data-testid="response-language-inline-step">
|
||||
<button onClick={() => onDismissNode?.('responseLanguage')}>dismiss-response-language</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('AgentOnboardingConversation', () => {
|
||||
beforeEach(() => {
|
||||
chatInputSpy.mockClear();
|
||||
@@ -76,6 +107,7 @@ describe('AgentOnboardingConversation', () => {
|
||||
|
||||
render(
|
||||
<AgentOnboardingConversation
|
||||
activeNode="responseLanguage"
|
||||
currentQuestion={
|
||||
{
|
||||
id: 'response-language-question',
|
||||
@@ -88,10 +120,8 @@ describe('AgentOnboardingConversation', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chat-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-item-assistant-1')).toContainElement(
|
||||
screen.getByTestId('structured-actions'),
|
||||
);
|
||||
expect(screen.getByTestId('structured-actions')).toHaveTextContent('responseLanguage');
|
||||
expect(screen.getByTestId('response-language-inline-step')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('structured-actions')).not.toBeInTheDocument();
|
||||
expect(chatInputSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowExpand: false,
|
||||
@@ -106,6 +136,7 @@ describe('AgentOnboardingConversation', () => {
|
||||
|
||||
render(
|
||||
<AgentOnboardingConversation
|
||||
activeNode="agentIdentity"
|
||||
currentQuestion={
|
||||
{
|
||||
id: 'agent-identity-question',
|
||||
@@ -130,6 +161,7 @@ describe('AgentOnboardingConversation', () => {
|
||||
|
||||
render(
|
||||
<AgentOnboardingConversation
|
||||
activeNode="agentIdentity"
|
||||
currentQuestion={
|
||||
{
|
||||
id: 'agent-identity-question',
|
||||
@@ -159,6 +191,7 @@ describe('AgentOnboardingConversation', () => {
|
||||
|
||||
render(
|
||||
<AgentOnboardingConversation
|
||||
activeNode="responseLanguage"
|
||||
currentQuestion={
|
||||
{
|
||||
id: 'response-language-question',
|
||||
@@ -180,6 +213,7 @@ describe('AgentOnboardingConversation', () => {
|
||||
|
||||
render(
|
||||
<AgentOnboardingConversation
|
||||
activeNode="agentIdentity"
|
||||
currentQuestion={
|
||||
{
|
||||
id: 'agent-identity-question',
|
||||
@@ -203,6 +237,7 @@ describe('AgentOnboardingConversation', () => {
|
||||
render(
|
||||
<AgentOnboardingConversation
|
||||
readOnly
|
||||
activeNode="agentIdentity"
|
||||
currentQuestion={
|
||||
{
|
||||
id: 'agent-identity-question',
|
||||
@@ -218,4 +253,23 @@ describe('AgentOnboardingConversation', () => {
|
||||
expect(screen.queryByTestId('structured-actions')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the built-in response language step even without an AI question surface', () => {
|
||||
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
|
||||
|
||||
render(<AgentOnboardingConversation activeNode="responseLanguage" />);
|
||||
|
||||
expect(screen.getByTestId('response-language-inline-step')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('structured-actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the built-in response language step after it is dismissed locally', () => {
|
||||
mockState.displayMessages = [{ id: 'assistant-1', role: 'assistant' }];
|
||||
|
||||
render(<AgentOnboardingConversation activeNode="responseLanguage" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dismiss-response-language' }));
|
||||
|
||||
expect(screen.queryByTestId('response-language-inline-step')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,13 @@ import { useAgentMeta } from '@/features/Conversation/hooks/useAgentMeta';
|
||||
import { isDev } from '@/utils/env';
|
||||
|
||||
import QuestionRenderer from './QuestionRenderer';
|
||||
import ResponseLanguageInlineStep from './ResponseLanguageInlineStep';
|
||||
import { staticStyle } from './staticStyle';
|
||||
|
||||
const assistantLikeRoles = new Set(['assistant', 'assistantGroup', 'supervisor']);
|
||||
|
||||
interface AgentOnboardingConversationProps {
|
||||
activeNode?: UserAgentOnboardingNode;
|
||||
currentQuestion?: UserAgentOnboardingQuestion;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -30,14 +32,14 @@ interface AgentOnboardingConversationProps {
|
||||
const chatInputLeftActions: ActionKeys[] = isDev ? ['model'] : [];
|
||||
|
||||
const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
|
||||
({ currentQuestion, readOnly }) => {
|
||||
({ activeNode, currentQuestion, readOnly }) => {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const agentMeta = useAgentMeta();
|
||||
const [dismissedNodes, setDismissedNodes] = useState<string[]>([]);
|
||||
const displayMessages = useConversationStore(conversationSelectors.displayMessages);
|
||||
const questionSignature = useMemo(
|
||||
() => JSON.stringify(currentQuestion || null),
|
||||
[currentQuestion],
|
||||
() => JSON.stringify({ activeNode, currentQuestion: currentQuestion || null }),
|
||||
[activeNode, currentQuestion],
|
||||
);
|
||||
const lastQuestionSignatureRef = useRef(questionSignature);
|
||||
|
||||
@@ -55,6 +57,7 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
|
||||
|
||||
return dismissedNodeSet.has(currentQuestion.node) ? undefined : currentQuestion;
|
||||
}, [currentQuestion, dismissedNodes, readOnly]);
|
||||
const shouldRenderResponseLanguageStep = !readOnly && activeNode === 'responseLanguage';
|
||||
|
||||
const lastAssistantMessageId = useMemo(() => {
|
||||
for (const message of [...displayMessages].reverse()) {
|
||||
@@ -109,10 +112,20 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
|
||||
const isLatestItem = displayMessages.length === index + 1;
|
||||
|
||||
const effectiveQuestion =
|
||||
isGreetingState && !visibleQuestion ? presetGreetingQuestion : visibleQuestion;
|
||||
!readOnly && isGreetingState && !currentQuestion
|
||||
? presetGreetingQuestion
|
||||
: visibleQuestion;
|
||||
const effectiveStep =
|
||||
shouldRenderResponseLanguageStep && !dismissedNodes.includes('responseLanguage')
|
||||
? 'responseLanguage'
|
||||
: undefined;
|
||||
|
||||
const endRender =
|
||||
id === lastAssistantMessageId && effectiveQuestion ? (
|
||||
id !== lastAssistantMessageId ? undefined : effectiveStep ? (
|
||||
<div className={staticStyle.inlineQuestion}>
|
||||
<ResponseLanguageInlineStep onDismissNode={handleDismissNode} />
|
||||
</div>
|
||||
) : effectiveQuestion ? (
|
||||
<div className={staticStyle.inlineQuestion}>
|
||||
<QuestionRenderer
|
||||
currentQuestion={effectiveQuestion}
|
||||
@@ -165,6 +178,10 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
|
||||
isGreetingState,
|
||||
lastAssistantMessageId,
|
||||
presetGreetingQuestion,
|
||||
readOnly,
|
||||
currentQuestion,
|
||||
dismissedNodes,
|
||||
shouldRenderResponseLanguageStep,
|
||||
visibleQuestion,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ResponseLanguageInlineStep from './ResponseLanguageInlineStep';
|
||||
|
||||
const questionRendererViewSpy = vi.fn();
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./questionRendererRuntime', () => ({
|
||||
useQuestionRendererRuntime: () => ({
|
||||
fallbackQuestionDescription: 'fallback-description',
|
||||
fallbackTextFieldLabel: 'fallback-label',
|
||||
fallbackTextFieldPlaceholder: 'fallback-placeholder',
|
||||
loading: false,
|
||||
nextLabel: 'next',
|
||||
onChangeResponseLanguage: vi.fn(),
|
||||
onSendMessage: vi.fn(),
|
||||
responseLanguageOptions: [{ label: 'English', value: 'en-US' }],
|
||||
submitLabel: 'submit',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/user', () => ({
|
||||
useUserStore: (selector: (state: Record<string, never>) => unknown) => selector({}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/user/selectors', () => ({
|
||||
userGeneralSettingsSelectors: {
|
||||
currentResponseLanguage: () => 'en-US',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./QuestionRendererView', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
questionRendererViewSpy(props);
|
||||
|
||||
return <div data-testid="question-renderer-view" />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ResponseLanguageInlineStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the built-in response-language node through the shared question UI', () => {
|
||||
const onDismissNode = vi.fn();
|
||||
|
||||
render(<ResponseLanguageInlineStep onDismissNode={onDismissNode} />);
|
||||
|
||||
expect(screen.getByTestId('question-renderer-view')).toBeInTheDocument();
|
||||
const [props] = questionRendererViewSpy.mock.calls[0];
|
||||
|
||||
expect(props).toEqual(
|
||||
expect.objectContaining({
|
||||
currentQuestion: {
|
||||
description: 'responseLanguage.desc',
|
||||
fields: [
|
||||
{
|
||||
key: 'responseLanguage',
|
||||
kind: 'select',
|
||||
label: 'agent.stage.responseLanguage',
|
||||
value: 'en-US',
|
||||
},
|
||||
],
|
||||
id: 'builtin-response-language',
|
||||
mode: 'select',
|
||||
node: 'responseLanguage',
|
||||
prompt: 'responseLanguage.title',
|
||||
submitMode: 'message',
|
||||
},
|
||||
onDismissNode,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
55
src/features/Onboarding/Agent/ResponseLanguageInlineStep.tsx
Normal file
55
src/features/Onboarding/Agent/ResponseLanguageInlineStep.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import type { UserAgentOnboardingNode, UserAgentOnboardingQuestion } from '@lobechat/types';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { useQuestionRendererRuntime } from './questionRendererRuntime';
|
||||
import QuestionRendererView from './QuestionRendererView';
|
||||
|
||||
interface ResponseLanguageInlineStepProps {
|
||||
onDismissNode?: (node: UserAgentOnboardingNode) => void;
|
||||
}
|
||||
|
||||
const ResponseLanguageInlineStep = memo<ResponseLanguageInlineStepProps>(({ onDismissNode }) => {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const runtime = useQuestionRendererRuntime();
|
||||
const currentResponseLanguage = useUserStore(
|
||||
userGeneralSettingsSelectors.currentResponseLanguage,
|
||||
);
|
||||
|
||||
const currentQuestion = useMemo<UserAgentOnboardingQuestion>(
|
||||
() => ({
|
||||
description: t('responseLanguage.desc'),
|
||||
fields: [
|
||||
{
|
||||
key: 'responseLanguage',
|
||||
kind: 'select',
|
||||
label: t('agent.stage.responseLanguage'),
|
||||
value: currentResponseLanguage,
|
||||
},
|
||||
],
|
||||
id: 'builtin-response-language',
|
||||
mode: 'select',
|
||||
node: 'responseLanguage',
|
||||
prompt: t('responseLanguage.title'),
|
||||
submitMode: 'message',
|
||||
}),
|
||||
[currentResponseLanguage, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<QuestionRendererView
|
||||
currentQuestion={currentQuestion}
|
||||
onDismissNode={onDismissNode}
|
||||
{...runtime}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ResponseLanguageInlineStep.displayName = 'ResponseLanguageInlineStep';
|
||||
|
||||
export default ResponseLanguageInlineStep;
|
||||
@@ -24,6 +24,7 @@ describe('resolveAgentOnboardingContext', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
activeNode: 'userIdentity',
|
||||
currentQuestion: undefined,
|
||||
topicId: 'topic-bootstrap',
|
||||
});
|
||||
@@ -49,6 +50,7 @@ describe('resolveAgentOnboardingContext', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
activeNode: 'workStyle',
|
||||
currentQuestion: undefined,
|
||||
topicId: 'topic-bootstrap',
|
||||
});
|
||||
@@ -84,6 +86,7 @@ describe('resolveAgentOnboardingContext', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
activeNode: 'agentIdentity',
|
||||
currentQuestion: {
|
||||
id: 'agent_identity_001',
|
||||
mode: 'button_group',
|
||||
@@ -113,6 +116,7 @@ describe('resolveAgentOnboardingContext', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
activeNode: 'userIdentity',
|
||||
currentQuestion: undefined,
|
||||
topicId: undefined,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { UserAgentOnboardingNode } from '@lobechat/types';
|
||||
import { AGENT_ONBOARDING_NODES } from '@lobechat/types';
|
||||
|
||||
import type { UserAgentOnboarding, UserAgentOnboardingQuestion } from '@/types/user';
|
||||
|
||||
export interface AgentOnboardingBootstrapContext {
|
||||
agentOnboarding: UserAgentOnboarding;
|
||||
context: {
|
||||
activeNode?: UserAgentOnboardingNode;
|
||||
currentQuestion?: UserAgentOnboardingQuestion;
|
||||
};
|
||||
topicId: string;
|
||||
@@ -13,10 +17,22 @@ interface ResolveAgentOnboardingContextParams {
|
||||
storedAgentOnboarding?: UserAgentOnboarding;
|
||||
}
|
||||
|
||||
const resolveQuestionFromSurface = (state?: UserAgentOnboarding) => {
|
||||
const getActiveNodeFromState = (state?: UserAgentOnboarding) => {
|
||||
if (state?.finishedAt) return undefined;
|
||||
|
||||
const completedNodeSet = new Set(state?.completedNodes ?? []);
|
||||
|
||||
return AGENT_ONBOARDING_NODES.find((node) => !completedNodeSet.has(node));
|
||||
};
|
||||
|
||||
const resolveQuestionFromSurface = (
|
||||
state: UserAgentOnboarding | undefined,
|
||||
activeNode: UserAgentOnboardingNode | undefined,
|
||||
) => {
|
||||
const questionSurface = state?.questionSurface;
|
||||
|
||||
if (!questionSurface) return undefined;
|
||||
if (questionSurface.node !== activeNode) return undefined;
|
||||
if (state?.completedNodes?.includes(questionSurface.node)) return undefined;
|
||||
|
||||
return questionSurface.question;
|
||||
@@ -25,10 +41,23 @@ const resolveQuestionFromSurface = (state?: UserAgentOnboarding) => {
|
||||
export const resolveAgentOnboardingContext = ({
|
||||
bootstrapContext,
|
||||
storedAgentOnboarding,
|
||||
}: ResolveAgentOnboardingContextParams) => ({
|
||||
currentQuestion:
|
||||
bootstrapContext?.context.currentQuestion ||
|
||||
resolveQuestionFromSurface(storedAgentOnboarding) ||
|
||||
resolveQuestionFromSurface(bootstrapContext?.agentOnboarding),
|
||||
topicId: bootstrapContext?.topicId ?? storedAgentOnboarding?.activeTopicId,
|
||||
});
|
||||
}: ResolveAgentOnboardingContextParams) => {
|
||||
const activeNode =
|
||||
getActiveNodeFromState(storedAgentOnboarding) ||
|
||||
bootstrapContext?.context.activeNode ||
|
||||
getActiveNodeFromState(bootstrapContext?.agentOnboarding);
|
||||
|
||||
const bootstrapCurrentQuestion =
|
||||
bootstrapContext?.context.currentQuestion?.node === activeNode
|
||||
? bootstrapContext?.context.currentQuestion
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
activeNode,
|
||||
currentQuestion:
|
||||
bootstrapCurrentQuestion ||
|
||||
resolveQuestionFromSurface(storedAgentOnboarding, activeNode) ||
|
||||
resolveQuestionFromSurface(bootstrapContext?.agentOnboarding, activeNode),
|
||||
topicId: bootstrapContext?.topicId ?? storedAgentOnboarding?.activeTopicId,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -131,6 +131,7 @@ const AgentOnboardingPage = memo(() => {
|
||||
>
|
||||
<ErrorBoundary FallbackComponent={() => null}>
|
||||
<AgentOnboardingConversation
|
||||
activeNode={viewingHistoricalTopic ? undefined : currentContext.activeNode}
|
||||
readOnly={viewingHistoricalTopic}
|
||||
currentQuestion={
|
||||
viewingHistoricalTopic ? undefined : currentContext.currentQuestion
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Undo2Icon } from 'lucide-react';
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { type Locales } from '@/locales/resources';
|
||||
import type { Locales } from '@/locales/resources';
|
||||
import { localeOptions, normalizeLocale } from '@/locales/resources';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { useUserStore } from '@/store/user';
|
||||
@@ -24,7 +24,7 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
|
||||
const switchLocale = useGlobalStore((s) => s.switchLocale);
|
||||
const setSettings = useUserStore((s) => s.setSettings);
|
||||
|
||||
const [value, setValue] = useState<Locales | ''>(normalizeLocale(navigator.language));
|
||||
const [value, setValue] = useState<Locales | ''>(() => normalizeLocale(navigator.language));
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
const isNavigatingRef = useRef(false);
|
||||
|
||||
@@ -54,7 +54,7 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
|
||||
]}
|
||||
/>
|
||||
),
|
||||
[t, value],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user