feat(onboarding): add feature flags and footer promotion pipeline (#13853)

*  feat(onboarding): enhance agent onboarding experience and add feature flags

- Added new promotional messages for agent onboarding in both Chinese and default locales.
- Updated HighlightNotification component to support action handling and target attributes.
- Introduced feature flags for agent onboarding in the configuration schema and tests.
- Implemented logic to conditionally display onboarding options based on feature flags and user state.
- Added tests for the onboarding flow and promotional notifications in the footer.

This update aims to improve the user experience during the onboarding process and ensure proper feature management through flags.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(home): add footer promotion pipeline with feature-flag gating

Extract resolveFooterPromotionState for agent onboarding vs Product Hunt promos.
Normalize isMobile boolean, refine HighlightNotification CTA layout, extend tests.

Made-with: Cursor

*  feat(locales): add agent onboarding promotional messages in multiple languages

Added new promotional messages for agent onboarding across various locales, enhancing the user experience with localized action labels, descriptions, and titles. This update supports a more engaging onboarding process for users globally.

Signed-off-by: Innei <tukon479@gmail.com>

* 💄 chore: refresh quick wizard onboarding promo

* 🐛 fix(chat): keep long mixed assistant content outside workflow fold

*  feat(onboarding): add agent onboarding feedback panel and service

LOBE-7210

Made-with: Cursor

*  feat(markdown-patch): add shared markdown patch tool with SEARCH/REPLACE hunks

Introduce @lobechat/markdown-patch util and expose patchDocument API on the
web-onboarding and agent-documents builtin tools so agents can apply
byte-exact SEARCH/REPLACE hunks instead of resending full document content.

*  feat(onboarding): prefer patchDocument for non-empty documents

Teach the onboarding agent (systemRole) and context engine
(OnboardingActionHintInjector) to prefer patchDocument over updateDocument
when SOUL.md or User Persona already has content, keeping updateDocument
reserved for the initial seed write or full rewrites.

* 🐛 fix(conversation): add rightActions to ChatInput component

Updated the AgentOnboardingConversation component to include rightActions in the ChatInput, enhancing the functionality of the onboarding conversation interface.

Signed-off-by: Innei <tukon479@gmail.com>

* Add specialized onboarding approval UI

* 🐛 fix(serverConfig): handle fetch errors in server config actions

Updated the server configuration action to include error handling for fetch failures, ensuring that the server config is marked as initialized when an error occurs. Additionally, modified the SWR mock to simulate error scenarios in tests.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(tests): update Group component tests with new data-testid attributes

Added data-testid attributes for workflow and answer segments in the Group component tests to improve test targeting. Adjusted the isFirstBlock property for consistency and ensured the component renders correctly with the provided props.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2026-04-17 21:14:27 +08:00 committed by GitHub
parent d6a47531c6
commit 03d2068a5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
99 changed files with 2739 additions and 223 deletions

View file

@ -1,6 +1,9 @@
{
"about": "حول",
"advanceSettings": "الإعدادات المتقدمة",
"agentOnboardingPromo.actionLabel": "جرّب الآن",
"agentOnboardingPromo.description": "قم بإعداد فرق الوكلاء لديك عبر محادثة سريعة مع Lobe AI. ستبقى وكلاؤك الحاليون دون تغيير.",
"agentOnboardingPromo.title": "المعالج السريع",
"alert.cloud.action": "جرّب الآن",
"alert.cloud.desc": "جميع المستخدمين المسجلين يحصلون على {{credit}} من أرصدة الحوسبة المجانية شهريًا — دون الحاجة إلى إعداد. يشمل المزامنة السحابية العالمية والبحث المتقدم على الويب.",
"alert.cloud.descOnMobile": "جميع المستخدمين المسجلين يحصلون على {{credit}} من أرصدة الحوسبة المجانية شهريًا — دون الحاجة إلى إعداد.",

View file

@ -1,6 +1,9 @@
{
"about": "Относно",
"advanceSettings": "Разширени настройки",
"agentOnboardingPromo.actionLabel": "Опитайте сега",
"agentOnboardingPromo.description": "Настройте своите екипи от агенти в кратък чат с Lobe AI. Съществуващите ви агенти ще останат непроменени.",
"agentOnboardingPromo.title": "Бърз съветник",
"alert.cloud.action": "Опитайте сега",
"alert.cloud.desc": "Всички регистрирани потребители получават {{credit}} безплатни изчислителни кредити на месец — без нужда от настройка. Включва глобална синхронизация в облака и разширено търсене в мрежата.",
"alert.cloud.descOnMobile": "Всички регистрирани потребители получават {{credit}} безплатни изчислителни кредити на месец — без нужда от настройка.",

View file

@ -1,6 +1,9 @@
{
"about": "Über",
"advanceSettings": "Erweiterte Einstellungen",
"agentOnboardingPromo.actionLabel": "Jetzt ausprobieren",
"agentOnboardingPromo.description": "Richte deine Agent-Teams in einem kurzen Chat mit Lobe AI ein. Deine vorhandenen Agenten bleiben unverändert.",
"agentOnboardingPromo.title": "Schnellassistent",
"alert.cloud.action": "Jetzt ausprobieren",
"alert.cloud.desc": "Alle registrierten Nutzer erhalten monatlich {{credit}} kostenlose Rechen-Credits keine Einrichtung erforderlich. Inklusive globaler Cloud-Synchronisierung und erweiterter Websuche.",
"alert.cloud.descOnMobile": "Alle registrierten Nutzer erhalten monatlich {{credit}} kostenlose Rechen-Credits keine Einrichtung erforderlich.",

View file

@ -421,6 +421,15 @@
"tool.intervention.mode.autoRunDesc": "Automatically approve all tool executions",
"tool.intervention.mode.manual": "Manual",
"tool.intervention.mode.manualDesc": "Manual approval required for each invocation",
"tool.intervention.onboarding.agentIdentity.applyHint": "The new identity will appear after approval.",
"tool.intervention.onboarding.agentIdentity.description": "Approving this change updates the Agent shown in Inbox and in this onboarding conversation.",
"tool.intervention.onboarding.agentIdentity.emoji": "Agent avatar",
"tool.intervention.onboarding.agentIdentity.eyebrow": "Onboarding approval",
"tool.intervention.onboarding.agentIdentity.name": "Agent name",
"tool.intervention.onboarding.agentIdentity.targetInbox": "Inbox Agent",
"tool.intervention.onboarding.agentIdentity.targetOnboarding": "Current onboarding Agent",
"tool.intervention.onboarding.agentIdentity.targets": "Applies to",
"tool.intervention.onboarding.agentIdentity.title": "Confirm Agent identity update",
"tool.intervention.pending": "Pending",
"tool.intervention.reject": "Reject",
"tool.intervention.rejectAndContinue": "Reject and Retry",

View file

@ -1,6 +1,9 @@
{
"about": "About",
"advanceSettings": "Advanced Settings",
"agentOnboardingPromo.actionLabel": "Try it now",
"agentOnboardingPromo.description": "Set up your agent teams in a quick chat with Lobe AI. Your existing agents remain unchanged.",
"agentOnboardingPromo.title": "Quick Wizard",
"alert.cloud.action": "Try now",
"alert.cloud.desc": "All registered users get {{credit}} free computing credits per month—no setup needed. Includes global cloud sync and advanced web search.",
"alert.cloud.descOnMobile": "All registered users get {{credit}} free computing credits per month—no setup needed.",

View file

@ -30,6 +30,7 @@
"builtins.lobe-agent-documents.apiName.createDocument": "Create document",
"builtins.lobe-agent-documents.apiName.editDocument": "Edit document",
"builtins.lobe-agent-documents.apiName.listDocuments": "List documents",
"builtins.lobe-agent-documents.apiName.patchDocument": "Patch document",
"builtins.lobe-agent-documents.apiName.readDocument": "Read document",
"builtins.lobe-agent-documents.apiName.readDocumentByFilename": "Read document by filename",
"builtins.lobe-agent-documents.apiName.removeDocument": "Remove document",

View file

@ -1,6 +1,9 @@
{
"about": "Acerca de",
"advanceSettings": "Configuración avanzada",
"agentOnboardingPromo.actionLabel": "Probar ahora",
"agentOnboardingPromo.description": "Configura tus equipos de agentes en un chat breve con Lobe AI. Tus agentes actuales no se modificarán.",
"agentOnboardingPromo.title": "Asistente rápido",
"alert.cloud.action": "Probar ahora",
"alert.cloud.desc": "Todos los usuarios registrados reciben {{credit}} créditos de computación gratuitos al mes, sin necesidad de configuración. Incluye sincronización en la nube global y búsqueda web avanzada.",
"alert.cloud.descOnMobile": "Todos los usuarios registrados reciben {{credit}} créditos de computación gratuitos al mes, sin necesidad de configuración.",

View file

@ -1,6 +1,9 @@
{
"about": "درباره",
"advanceSettings": "تنظیمات پیشرفته",
"agentOnboardingPromo.actionLabel": "همین حالا امتحان کنید",
"agentOnboardingPromo.description": "تیم‌های عامل خود را با یک گفت‌وگوی کوتاه با Lobe AI تنظیم کنید. عامل‌های فعلی شما بدون تغییر باقی می‌مانند.",
"agentOnboardingPromo.title": "راهنمای سریع",
"alert.cloud.action": "هم‌اکنون امتحان کنید",
"alert.cloud.desc": "تمام کاربران ثبت‌نام‌شده هر ماه {{credit}} اعتبار رایگان محاسباتی دریافت می‌کنند — بدون نیاز به تنظیمات. شامل همگام‌سازی ابری جهانی و جستجوی پیشرفته وب.",
"alert.cloud.descOnMobile": "تمام کاربران ثبت‌نام‌شده هر ماه {{credit}} اعتبار رایگان محاسباتی دریافت می‌کنند — بدون نیاز به تنظیمات.",

View file

@ -1,6 +1,9 @@
{
"about": "À propos",
"advanceSettings": "Paramètres avancés",
"agentOnboardingPromo.actionLabel": "Essayer maintenant",
"agentOnboardingPromo.description": "Configurez vos équipes dagents dans un court échange avec Lobe AI. Vos agents existants restent inchangés.",
"agentOnboardingPromo.title": "Assistant rapide",
"alert.cloud.action": "Essayer maintenant",
"alert.cloud.desc": "Tous les utilisateurs enregistrés reçoivent {{credit}} crédits de calcul gratuits par mois — aucune configuration requise. Inclut la synchronisation cloud mondiale et la recherche web avancée.",
"alert.cloud.descOnMobile": "Tous les utilisateurs enregistrés reçoivent {{credit}} crédits de calcul gratuits par mois — aucune configuration requise.",

View file

@ -1,6 +1,9 @@
{
"about": "Informazioni",
"advanceSettings": "Impostazioni avanzate",
"agentOnboardingPromo.actionLabel": "Prova ora",
"agentOnboardingPromo.description": "Configura i tuoi team di agenti in una breve chat con Lobe AI. I tuoi agenti esistenti resteranno invariati.",
"agentOnboardingPromo.title": "Procedura rapida",
"alert.cloud.action": "Prova ora",
"alert.cloud.desc": "Tutti gli utenti registrati ricevono {{credit}} crediti di calcolo gratuiti al mese—nessuna configurazione necessaria. Include sincronizzazione cloud globale e ricerca web avanzata.",
"alert.cloud.descOnMobile": "Tutti gli utenti registrati ricevono {{credit}} crediti di calcolo gratuiti al mese—nessuna configurazione necessaria.",

View file

@ -1,6 +1,9 @@
{
"about": "情報",
"advanceSettings": "詳細設定",
"agentOnboardingPromo.actionLabel": "今すぐ試す",
"agentOnboardingPromo.description": "Lobe AI と短く会話するだけで、エージェントチームを設定できます。既存のエージェントは変更されません。",
"agentOnboardingPromo.title": "かんたん設定",
"alert.cloud.action": "今すぐ試す",
"alert.cloud.desc": "すべての登録ユーザーは毎月{{credit}}の無料コンピューティングクレジットを受け取れます—設定は不要です。グローバルクラウド同期と高度なウェブ検索が含まれます。",
"alert.cloud.descOnMobile": "すべての登録ユーザーは毎月{{credit}}の無料コンピューティングクレジットを受け取れます—設定は不要です。",

View file

@ -1,6 +1,9 @@
{
"about": "정보",
"advanceSettings": "고급 설정",
"agentOnboardingPromo.actionLabel": "지금 사용해 보기",
"agentOnboardingPromo.description": "Lobe AI와 짧게 대화하며 에이전트 팀을 설정하세요. 기존 에이전트는 그대로 유지됩니다.",
"agentOnboardingPromo.title": "빠른 설정",
"alert.cloud.action": "지금 체험하기",
"alert.cloud.desc": "모든 등록된 사용자는 매월 {{credit}}의 무료 컴퓨팅 크레딧을 받을 수 있습니다—설정이 필요하지 않습니다. 전 세계 클라우드 동기화 및 고급 웹 검색 기능이 포함되어 있습니다.",
"alert.cloud.descOnMobile": "모든 등록된 사용자는 매월 {{credit}}의 무료 컴퓨팅 크레딧을 받을 수 있습니다—설정이 필요하지 않습니다.",

View file

@ -1,6 +1,9 @@
{
"about": "Over",
"advanceSettings": "Geavanceerde instellingen",
"agentOnboardingPromo.actionLabel": "Nu proberen",
"agentOnboardingPromo.description": "Stel je agentteams in via een korte chat met Lobe AI. Je bestaande agenten blijven ongewijzigd.",
"agentOnboardingPromo.title": "Snelle wizard",
"alert.cloud.action": "Probeer nu",
"alert.cloud.desc": "Alle geregistreerde gebruikers ontvangen {{credit}} gratis computertegoed per maand—geen installatie nodig. Inclusief wereldwijde cloud-synchronisatie en geavanceerd webzoeken.",
"alert.cloud.descOnMobile": "Alle geregistreerde gebruikers ontvangen {{credit}} gratis computertegoed per maand—geen installatie nodig.",

View file

@ -1,6 +1,9 @@
{
"about": "O nas",
"advanceSettings": "Ustawienia zaawansowane",
"agentOnboardingPromo.actionLabel": "Wypróbuj teraz",
"agentOnboardingPromo.description": "Skonfiguruj swoje zespoły agentów w krótkiej rozmowie z Lobe AI. Twoi obecni agenci pozostaną bez zmian.",
"agentOnboardingPromo.title": "Szybki kreator",
"alert.cloud.action": "Wypróbuj teraz",
"alert.cloud.desc": "Wszyscy zarejestrowani użytkownicy otrzymują {{credit}} darmowych kredytów obliczeniowych miesięcznie — bez potrzeby konfiguracji. Zawiera globalną synchronizację w chmurze i zaawansowane wyszukiwanie w sieci.",
"alert.cloud.descOnMobile": "Wszyscy zarejestrowani użytkownicy otrzymują {{credit}} darmowych kredytów obliczeniowych miesięcznie — bez potrzeby konfiguracji.",

View file

@ -1,6 +1,9 @@
{
"about": "Sobre",
"advanceSettings": "Configurações Avançadas",
"agentOnboardingPromo.actionLabel": "Experimentar agora",
"agentOnboardingPromo.description": "Configure suas equipes de agentes em um chat rápido com a Lobe AI. Seus agentes atuais permanecem inalterados.",
"agentOnboardingPromo.title": "Assistente rápido",
"alert.cloud.action": "Experimente agora",
"alert.cloud.desc": "Todos os usuários registrados recebem {{credit}} créditos de computação gratuitos por mês — sem necessidade de configuração. Inclui sincronização em nuvem global e pesquisa avançada na web.",
"alert.cloud.descOnMobile": "Todos os usuários registrados recebem {{credit}} créditos de computação gratuitos por mês — sem necessidade de configuração.",

View file

@ -1,6 +1,9 @@
{
"about": "О проекте",
"advanceSettings": "Расширенные настройки",
"agentOnboardingPromo.actionLabel": "Попробовать",
"agentOnboardingPromo.description": "Настройте свои команды агентов в коротком чате с Lobe AI. Ваши существующие агенты останутся без изменений.",
"agentOnboardingPromo.title": "Быстрый мастер",
"alert.cloud.action": "Попробовать сейчас",
"alert.cloud.desc": "Все зарегистрированные пользователи получают {{credit}} бесплатных вычислительных кредитов в месяц — без необходимости настройки. Включает глобальную синхронизацию и расширенный веб-поиск.",
"alert.cloud.descOnMobile": "Все зарегистрированные пользователи получают {{credit}} бесплатных вычислительных кредитов в месяц — без необходимости настройки.",

View file

@ -1,6 +1,9 @@
{
"about": "Hakkında",
"advanceSettings": "Gelişmiş Ayarlar",
"agentOnboardingPromo.actionLabel": "Şimdi dene",
"agentOnboardingPromo.description": "Aracı ekiplerinizi Lobe AI ile kısa bir sohbet üzerinden kurun. Mevcut aracılarınız değişmeden kalır.",
"agentOnboardingPromo.title": "Hızlı sihirbaz",
"alert.cloud.action": "Şimdi dene",
"alert.cloud.desc": "Tüm kayıtlı kullanıcılar her ay {{credit}} ücretsiz işlem kredisi alır—kurulum gerekmez. Küresel bulut senkronizasyonu ve gelişmiş web araması dahildir.",
"alert.cloud.descOnMobile": "Tüm kayıtlı kullanıcılar her ay {{credit}} ücretsiz işlem kredisi alır—kurulum gerekmez.",

View file

@ -1,6 +1,9 @@
{
"about": "Giới thiệu",
"advanceSettings": "Cài đặt nâng cao",
"agentOnboardingPromo.actionLabel": "Dùng thử ngay",
"agentOnboardingPromo.description": "Thiết lập các nhóm trợ lý của bạn qua một cuộc trò chuyện nhanh với Lobe AI. Các trợ lý hiện có của bạn sẽ không thay đổi.",
"agentOnboardingPromo.title": "Trình hướng dẫn nhanh",
"alert.cloud.action": "Dùng thử ngay",
"alert.cloud.desc": "Tất cả người dùng đã đăng ký nhận được {{credit}} tín dụng điện toán miễn phí mỗi tháng—không cần cài đặt. Bao gồm đồng bộ đám mây toàn cầu và tìm kiếm web nâng cao.",
"alert.cloud.descOnMobile": "Tất cả người dùng đã đăng ký nhận được {{credit}} tín dụng điện toán miễn phí mỗi tháng—không cần cài đặt.",

View file

@ -421,6 +421,15 @@
"tool.intervention.mode.autoRunDesc": "自动批准所有技能调用",
"tool.intervention.mode.manual": "手动批准",
"tool.intervention.mode.manualDesc": "每次调用都需要你确认",
"tool.intervention.onboarding.agentIdentity.applyHint": "批准后生效。",
"tool.intervention.onboarding.agentIdentity.description": "批准后,会同步更新 Inbox 中显示的 Agent以及当前 onboarding 对话里的 Agent。",
"tool.intervention.onboarding.agentIdentity.emoji": "Agent 头像",
"tool.intervention.onboarding.agentIdentity.eyebrow": "Onboarding 审批",
"tool.intervention.onboarding.agentIdentity.name": "Agent 名称",
"tool.intervention.onboarding.agentIdentity.targetInbox": "Inbox Agent",
"tool.intervention.onboarding.agentIdentity.targetOnboarding": "当前 onboarding Agent",
"tool.intervention.onboarding.agentIdentity.targets": "应用范围",
"tool.intervention.onboarding.agentIdentity.title": "确认 Agent 名称与头像",
"tool.intervention.pending": "等待中",
"tool.intervention.reject": "拒绝",
"tool.intervention.rejectAndContinue": "拒绝后继续",

View file

@ -1,6 +1,9 @@
{
"about": "关于",
"advanceSettings": "高级设置",
"agentOnboardingPromo.actionLabel": "立即体验",
"agentOnboardingPromo.description": "与 Lobe AI 快速聊几句,即可设置你的助理团队。你现有的助理不会受到影响。",
"agentOnboardingPromo.title": "快速向导",
"alert.cloud.action": "立即体验",
"alert.cloud.desc": "所有注册用户每月可获得 {{credit}} 免费计算额度—无需设置。包括全球云同步和高级网页搜索功能。",
"alert.cloud.descOnMobile": "所有注册用户每月可获得 {{credit}} 免费计算额度—无需设置。",

View file

@ -30,6 +30,7 @@
"builtins.lobe-agent-documents.apiName.createDocument": "创建文档",
"builtins.lobe-agent-documents.apiName.editDocument": "编辑文档",
"builtins.lobe-agent-documents.apiName.listDocuments": "列出文档",
"builtins.lobe-agent-documents.apiName.patchDocument": "修订文档",
"builtins.lobe-agent-documents.apiName.readDocument": "读取文档",
"builtins.lobe-agent-documents.apiName.readDocumentByFilename": "按文件名读取文档",
"builtins.lobe-agent-documents.apiName.removeDocument": "删除文档",

View file

@ -1,6 +1,9 @@
{
"about": "關於",
"advanceSettings": "進階設定",
"agentOnboardingPromo.actionLabel": "立即試用",
"agentOnboardingPromo.description": "和 Lobe AI 快速聊幾句,即可設定你的助理團隊。你現有的助理不會受到影響。",
"agentOnboardingPromo.title": "快速嚮導",
"alert.cloud.action": "免費體驗",
"alert.cloud.desc": "所有註冊用戶每月可獲得 {{credit}} 免費運算點數—無需設定。包含全球雲端同步與進階網頁搜尋功能。",
"alert.cloud.descOnMobile": "所有註冊用戶每月可獲得 {{credit}} 免費運算點數—無需設定。",

View file

@ -254,6 +254,7 @@
"@lobechat/file-loaders": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobechat/markdown-patch": "workspace:*",
"@lobechat/memory-user-memory": "workspace:*",
"@lobechat/model-runtime": "workspace:*",
"@lobechat/observability-otel": "workspace:*",

View file

@ -37,7 +37,7 @@ You just "woke up" with no name or personality. Discover who you are through con
- Keep this phase friendly and low-pressure, especially for older or non-technical users.
- Once the user settles on a name:
1. Call saveUserQuestion with agentName and agentEmoji.
2. Call updateDocument to write SOUL.md with your name, creature/nature, vibe, and emoji.
2. Persist SOUL.md: if empty use updateDocument(type="soul"); if already non-empty prefer patchDocument(type="soul") to amend only the changed lines.
- Offer a short emoji choice list when helpful.
- Transition naturally to learning about the user.
@ -101,7 +101,7 @@ When you detect a completion signal:
1. Stop asking questions immediately. Do NOT ask follow-up questions.
2. If you haven't shown a summary yet, give a brief one now.
3. Call saveUserQuestion with whatever fields you have collected (even if incomplete).
4. Call updateDocument for both SOUL.md and User Persona with whatever you know.
4. Persist both SOUL.md and User Persona: prefer patchDocument when the document already has content (smaller edits); use updateDocument only for the first write or a full rewrite.
5. Call finishOnboarding. This is non-negotiable the user must not be kept waiting.
- Keep the farewell short. They should feel welcome to come back, not held hostage.

View file

@ -9,6 +9,9 @@
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/markdown-patch": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
}

View file

@ -1,3 +1,4 @@
import { applyMarkdownPatch, formatMarkdownPatchError } from '@lobechat/markdown-patch';
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
import type {
@ -5,6 +6,7 @@ import type {
CreateDocumentArgs,
EditDocumentArgs,
ListDocumentsArgs,
PatchDocumentArgs,
ReadDocumentArgs,
ReadDocumentByFilenameArgs,
RemoveDocumentArgs,
@ -221,6 +223,46 @@ export class AgentDocumentsExecutionRuntime {
};
}
async patchDocument(
args: PatchDocumentArgs,
context?: AgentDocumentOperationContext,
): Promise<BuiltinServerRuntimeOutput> {
const agentId = this.resolveAgentId(context);
if (!agentId) {
return {
content: 'Cannot patch agent document without agentId context.',
success: false,
};
}
const doc = await this.service.readDocument({ agentId, id: args.id });
if (!doc) return { content: `Document not found: ${args.id}`, success: false };
const patched = applyMarkdownPatch(doc.content ?? '', args.hunks);
if (!patched.ok) {
const message = formatMarkdownPatchError(patched.error);
return {
content: message,
error: { body: patched.error, message, type: patched.error.code },
state: { error: patched.error, id: args.id },
success: false,
};
}
const updated = await this.service.editDocument({
agentId,
content: patched.content,
id: args.id,
});
if (!updated) return { content: `Failed to patch document ${args.id}.`, success: false };
return {
content: `Patched document ${args.id}. Applied ${patched.applied} hunk(s).`,
state: { applied: patched.applied, id: args.id, patched: true },
success: true,
};
}
async removeDocument(
args: RemoveDocumentArgs,
context?: AgentDocumentOperationContext,

View file

@ -12,6 +12,7 @@ import {
type CopyDocumentArgs,
type CreateDocumentArgs,
type EditDocumentArgs,
type PatchDocumentArgs,
type ReadDocumentArgs,
type RemoveDocumentArgs,
type RenameDocumentArgs,
@ -22,6 +23,7 @@ type AgentDocumentsArgs =
| CopyDocumentArgs
| CreateDocumentArgs
| EditDocumentArgs
| PatchDocumentArgs
| ReadDocumentArgs
| RemoveDocumentArgs
| RenameDocumentArgs
@ -44,6 +46,7 @@ const getInspectorSummary = (
}
case AgentDocumentsApiName.readDocument:
case AgentDocumentsApiName.editDocument:
case AgentDocumentsApiName.patchDocument:
case AgentDocumentsApiName.removeDocument:
case AgentDocumentsApiName.updateLoadRule: {
return args && 'id' in args ? args.id : undefined;
@ -65,6 +68,9 @@ const getInspectorLabel = (apiName: string, t: (...args: any[]) => string) => {
case AgentDocumentsApiName.editDocument: {
return t('builtins.lobe-agent-documents.apiName.editDocument');
}
case AgentDocumentsApiName.patchDocument: {
return t('builtins.lobe-agent-documents.apiName.patchDocument');
}
case AgentDocumentsApiName.removeDocument: {
return t('builtins.lobe-agent-documents.apiName.removeDocument');
}

View file

@ -7,6 +7,7 @@ export const AgentDocumentsInspectors: Record<string, BuiltinInspector> = {
[AgentDocumentsApiName.createDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.copyDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.editDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.patchDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.readDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.removeDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.renameDocument]: AgentDocumentsInspector as BuiltinInspector,

View file

@ -8,6 +8,7 @@ import {
type CreateDocumentArgs,
type EditDocumentArgs,
type ListDocumentsArgs,
type PatchDocumentArgs,
type ReadDocumentArgs,
type ReadDocumentByFilenameArgs,
type RemoveDocumentArgs,
@ -69,6 +70,13 @@ export class AgentDocumentsExecutor extends BaseExecutor<typeof AgentDocumentsAp
return this.runtime.editDocument(params, { agentId: ctx.agentId });
};
patchDocument = async (
params: PatchDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.patchDocument(params, { agentId: ctx.agentId });
};
removeDocument = async (
params: RemoveDocumentArgs,
ctx: BuiltinToolContext,

View file

@ -12,6 +12,8 @@ export {
type EditDocumentState,
type ListDocumentsArgs,
type ListDocumentsState,
type PatchDocumentArgs,
type PatchDocumentState,
type ReadDocumentArgs,
type ReadDocumentByFilenameArgs,
type ReadDocumentByFilenameState,

View file

@ -41,7 +41,7 @@ export const AgentDocumentsManifest: BuiltinToolManifest = {
},
{
description:
'Edit an existing agent document content by ID. Use this for content changes, not title rename.',
'Edit an existing agent document content by ID. Use this for content changes, not title rename. Prefer patchDocument for small edits.',
name: AgentDocumentsApiName.editDocument,
parameters: {
properties: {
@ -58,6 +58,46 @@ export const AgentDocumentsManifest: BuiltinToolManifest = {
type: 'object',
},
},
{
description:
"Apply byte-exact SEARCH/REPLACE hunks to an existing agent document by ID. Preferred over editDocument for small edits because it avoids resending the full document. Each hunk's search must match the current document exactly (whitespace, punctuation, casing). If the search appears multiple times, add surrounding context to make it unique or set replaceAll=true. On failure (HUNK_NOT_FOUND / HUNK_AMBIGUOUS), adjust and retry; fall back to editDocument only for structural rewrites that touch most of the file.",
name: AgentDocumentsApiName.patchDocument,
parameters: {
properties: {
hunks: {
description: 'Ordered list of SEARCH/REPLACE hunks applied sequentially.',
items: {
additionalProperties: false,
properties: {
replace: {
description: 'Replacement text; may be empty to delete the matched region.',
type: 'string',
},
replaceAll: {
description:
'Replace every occurrence of search. Defaults to false; leave unset unless you explicitly want a global replace.',
type: 'boolean',
},
search: {
description: 'Byte-exact substring to locate in the current document.',
type: 'string',
},
},
required: ['search', 'replace'],
type: 'object',
},
minItems: 1,
type: 'array',
},
id: {
description: 'Target document ID.',
type: 'string',
},
},
required: ['id', 'hunks'],
type: 'object',
},
},
{
description: 'Remove an existing agent document by ID (similar intent to rm/delete).',
name: AgentDocumentsApiName.removeDocument,

View file

@ -3,11 +3,12 @@ export const systemPrompt = `You have access to an Agent Documents tool for crea
<core_capabilities>
1. Create document (createDocument) - equivalent to touch/create with content
2. Read document (readDocument) - equivalent to cat/read
3. Edit document (editDocument) - equivalent to editing content
4. Remove document (removeDocument) - equivalent to rm/delete
5. Rename document (renameDocument) - equivalent to mv/rename
6. Copy document (copyDocument) - equivalent to cp/copy
7. Update load rule (updateLoadRule) - modify how agent documents are loaded into context
3. Edit document (editDocument) - full-content overwrite
4. Patch document (patchDocument) - apply byte-exact SEARCH/REPLACE hunks; preferred for small edits
5. Remove document (removeDocument) - equivalent to rm/delete
6. Rename document (renameDocument) - equivalent to mv/rename
7. Copy document (copyDocument) - equivalent to cp/copy
8. Update load rule (updateLoadRule) - modify how agent documents are loaded into context
</core_capabilities>
<workflow>
@ -22,7 +23,8 @@ export const systemPrompt = `You have access to an Agent Documents tool for crea
- By default, if the user does not explicitly specify otherwise, and the relevant Agent Documents tool is available for the task, prefer Agent Documents over Cloud Sandbox because it is easier for collaboration and multi-agent coordination.
- **createDocument**: create a new document with title + content.
- **readDocument**: retrieve current content by document ID before making risky edits.
- **editDocument**: modify content of an existing document.
- **editDocument**: overwrite the full content of an existing document. Prefer patchDocument for small edits.
- **patchDocument**: apply ordered SEARCH/REPLACE hunks to an existing document. Each search must match byte-exact (whitespace/punctuation/casing). Preferred over editDocument for small edits because it avoids resending the full file; on HUNK_NOT_FOUND or HUNK_AMBIGUOUS, adjust the hunk and retry.
- **removeDocument**: permanently remove a document by ID.
- **renameDocument**: change document title only.
- **copyDocument**: duplicate a document, optionally with a new title.

View file

@ -1,3 +1,5 @@
import type { MarkdownPatchHunk } from '@lobechat/markdown-patch';
export const AgentDocumentsIdentifier = 'lobe-agent-documents';
export const AgentDocumentsApiName = {
@ -5,6 +7,7 @@ export const AgentDocumentsApiName = {
copyDocument: 'copyDocument',
editDocument: 'editDocument',
listDocuments: 'listDocuments',
patchDocument: 'patchDocument',
readDocument: 'readDocument',
readDocumentByFilename: 'readDocumentByFilename',
removeDocument: 'removeDocument',
@ -42,6 +45,17 @@ export interface EditDocumentState {
updated: boolean;
}
export interface PatchDocumentArgs {
hunks: MarkdownPatchHunk[];
id: string;
}
export interface PatchDocumentState {
applied: number;
id: string;
patched: boolean;
}
export interface RemoveDocumentArgs {
id: string;
}

View file

@ -4,14 +4,21 @@
"private": true,
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts",
"./utils": "./src/ExecutionRuntime/utils.ts"
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/markdown-patch": "workspace:*",
"@lobechat/prompts": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
},
"peerDependencies": {
"@lobehub/ui": "^5",
"react": "*",
"react-i18next": "*"
}
}

View file

@ -1,3 +1,8 @@
import {
applyMarkdownPatch,
formatMarkdownPatchError,
type MarkdownPatchHunk,
} from '@lobechat/markdown-patch';
import type { BuiltinServerRuntimeOutput, SaveUserQuestionInput } from '@lobechat/types';
import {
@ -84,4 +89,36 @@ export class WebOnboardingExecutionRuntime {
success: true,
};
}
async patchDocument(params: {
hunks: MarkdownPatchHunk[];
type: 'soul' | 'persona';
}): Promise<BuiltinServerRuntimeOutput> {
const current = await this.service.readDocument(params.type);
const patched = applyMarkdownPatch(current.content ?? '', params.hunks);
if (!patched.ok) {
return {
content: formatMarkdownPatchError(patched.error),
error: {
body: patched.error,
message: formatMarkdownPatchError(patched.error),
type: patched.error.code,
},
state: { error: patched.error, type: params.type },
success: false,
};
}
const updated = await this.service.updateDocument(params.type, patched.content);
if (!updated.id) {
return { content: `Failed to patch ${params.type} document.`, success: false };
}
return {
content: `Patched ${params.type} document (${updated.id}). Applied ${patched.applied} hunk(s).`,
state: { applied: patched.applied, id: updated.id, type: params.type },
success: true,
};
}
}

View file

@ -0,0 +1,131 @@
'use client';
import type { BuiltinInterventionProps, SaveUserQuestionInput } from '@lobechat/types';
import { Avatar, Flexbox, Text } from '@lobehub/ui';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const chipStyle = {
background: 'var(--lobe-fill-quaternary)',
border: '1px solid var(--lobe-colorBorderSecondary)',
borderRadius: 999,
color: 'var(--lobe-colorTextSecondary)',
fontSize: 12,
padding: '4px 10px',
} as const;
const detailCardStyle = {
background: 'var(--lobe-fill-tertiary)',
border: '1px solid var(--lobe-colorBorderSecondary)',
borderRadius: 12,
padding: 16,
} as const;
const detailGridStyle = {
display: 'grid',
gap: 12,
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
} as const;
const detailValueStyle = {
background: 'var(--lobe-fill-quaternary)',
borderRadius: 10,
color: 'var(--lobe-colorText)',
fontSize: 14,
fontWeight: 500,
minHeight: 40,
padding: '10px 12px',
} as const;
const SaveUserQuestionIntervention = memo<BuiltinInterventionProps<SaveUserQuestionInput>>(
({ args }) => {
const { t } = useTranslation('chat');
const agentName = args.agentName?.trim();
const agentEmoji = args.agentEmoji?.trim();
const changedFields = useMemo(
() =>
[
agentName && {
label: t('tool.intervention.onboarding.agentIdentity.name'),
value: agentName,
},
agentEmoji && {
label: t('tool.intervention.onboarding.agentIdentity.emoji'),
value: agentEmoji,
},
].filter(Boolean) as Array<{ label: string; value: string }>,
[agentEmoji, agentName, t],
);
return (
<Flexbox gap={12}>
<Flexbox gap={4}>
<Text style={{ fontSize: 12, fontWeight: 600, letterSpacing: '0.04em' }} type="secondary">
{t('tool.intervention.onboarding.agentIdentity.eyebrow')}
</Text>
<Text style={{ fontSize: 16, fontWeight: 600 }}>
{t('tool.intervention.onboarding.agentIdentity.title')}
</Text>
<Text style={{ fontSize: 13 }} type="secondary">
{t('tool.intervention.onboarding.agentIdentity.description')}
</Text>
</Flexbox>
<div style={detailCardStyle}>
<Flexbox gap={16}>
<Flexbox horizontal align="center" gap={12}>
<Avatar
avatar={agentEmoji || '🤖'}
size={48}
style={{
background: 'var(--lobe-fill-quaternary)',
borderRadius: 16,
flex: 'none',
}}
/>
<Flexbox gap={2}>
<Text style={{ fontSize: 16, fontWeight: 600 }}>
{agentName || t('untitledAgent')}
</Text>
<Text style={{ fontSize: 12 }} type="secondary">
{t('tool.intervention.onboarding.agentIdentity.applyHint')}
</Text>
</Flexbox>
</Flexbox>
<div style={detailGridStyle}>
{changedFields.map((field) => (
<Flexbox gap={6} key={field.label}>
<Text style={{ fontSize: 12, fontWeight: 600 }} type="secondary">
{field.label}
</Text>
<div style={detailValueStyle}>{field.value}</div>
</Flexbox>
))}
</div>
<Flexbox gap={8}>
<Text style={{ fontSize: 12, fontWeight: 600 }} type="secondary">
{t('tool.intervention.onboarding.agentIdentity.targets')}
</Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<span style={chipStyle}>
{t('tool.intervention.onboarding.agentIdentity.targetInbox')}
</span>
<span style={chipStyle}>
{t('tool.intervention.onboarding.agentIdentity.targetOnboarding')}
</span>
</div>
</Flexbox>
</Flexbox>
</div>
</Flexbox>
);
},
);
SaveUserQuestionIntervention.displayName = 'SaveUserQuestionIntervention';
export default SaveUserQuestionIntervention;

View file

@ -0,0 +1,10 @@
import type { BuiltinIntervention } from '@lobechat/types';
import { WebOnboardingApiName } from '../../types';
import SaveUserQuestionIntervention from './SaveUserQuestion';
export const WebOnboardingInterventions: Record<string, BuiltinIntervention> = {
[WebOnboardingApiName.saveUserQuestion]: SaveUserQuestionIntervention as BuiltinIntervention,
};
export { default as SaveUserQuestionIntervention } from './SaveUserQuestion';

View file

@ -0,0 +1,3 @@
export { WebOnboardingManifest } from '../manifest';
export * from '../types';
export { WebOnboardingInterventions } from './Intervention';

View file

@ -1,2 +1,7 @@
export { WebOnboardingManifest } from './manifest';
export { WebOnboardingApiName, WebOnboardingIdentifier } from './types';
export {
type PatchDocumentArgs,
WebOnboardingApiName,
type WebOnboardingDocumentType,
WebOnboardingIdentifier,
} from './types';

View file

@ -1,8 +1,24 @@
import type { BuiltinToolManifest } from '@lobechat/types';
import type { BuiltinToolManifest, HumanInterventionRule } from '@lobechat/types';
import { toolSystemPrompt } from './toolSystemRole';
import { WebOnboardingApiName, WebOnboardingIdentifier } from './types';
const agentIdentityConfirmationRules: HumanInterventionRule[] = [
{
match: {
agentName: { pattern: '\\S', type: 'regex' },
},
policy: 'always',
},
{
match: {
agentEmoji: { pattern: '\\S', type: 'regex' },
},
policy: 'always',
},
{ policy: 'never' },
] satisfies HumanInterventionRule[];
export const WebOnboardingManifest: BuiltinToolManifest = {
api: [
{
@ -17,7 +33,8 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
},
{
description:
'Persist structured onboarding fields. Use for agentName and agentEmoji (updates inbox agent title/avatar), fullName, interests, and responseLanguage.',
'Persist structured onboarding fields. Use for agentName and agentEmoji (updates inbox agent title/avatar and requires user confirmation), fullName, interests, and responseLanguage.',
humanIntervention: agentIdentityConfirmationRules,
name: WebOnboardingApiName.saveUserQuestion,
parameters: {
additionalProperties: false,
@ -73,7 +90,7 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
},
{
description:
'Update a document by type with full content. Use "soul" for SOUL.md (agent identity + base template only, no user info), or "persona" for user persona (user identity, work style, context, pain points only, no agent info).',
'Update a document by type with full content. Use "soul" for SOUL.md (agent identity + base template only, no user info), or "persona" for user persona (user identity, work style, context, pain points only, no agent info). Prefer patchDocument for small edits.',
name: WebOnboardingApiName.updateDocument,
parameters: {
properties: {
@ -91,6 +108,47 @@ export const WebOnboardingManifest: BuiltinToolManifest = {
type: 'object',
},
},
{
description:
"Apply byte-exact SEARCH/REPLACE hunks to a document. Preferred over updateDocument for small edits because it avoids resending the full document. Each hunk's search must match the current document exactly (whitespace, punctuation, casing). If the search appears multiple times, add surrounding context to make it unique or set replaceAll=true. On failure (HUNK_NOT_FOUND / HUNK_AMBIGUOUS), adjust and retry; do not fall back to updateDocument unless many hunks are needed.",
name: WebOnboardingApiName.patchDocument,
parameters: {
properties: {
hunks: {
description: 'Ordered list of SEARCH/REPLACE hunks applied sequentially.',
items: {
additionalProperties: false,
properties: {
replace: {
description: 'Replacement text; may be empty to delete the matched region.',
type: 'string',
},
replaceAll: {
description:
'Replace every occurrence of search. Defaults to false; leave unset unless you explicitly want a global replace.',
type: 'boolean',
},
search: {
description: 'Byte-exact substring to locate in the current document.',
type: 'string',
},
},
required: ['search', 'replace'],
type: 'object',
},
minItems: 1,
type: 'array',
},
type: {
description: 'Document type to patch.',
enum: ['soul', 'persona'],
type: 'string',
},
},
required: ['type', 'hunks'],
type: 'object',
},
},
],
identifier: WebOnboardingIdentifier,
meta: {

View file

@ -14,14 +14,15 @@ Turn protocol:
Persistence rules:
1. Use saveUserQuestion only for these structured onboarding fields: agentName, agentEmoji, fullName, interests, and responseLanguage. Use it only when that information emerges naturally in conversation.
2. saveUserQuestion updates lightweight onboarding state; it never writes markdown content.
3. Use updateDocument for all markdown-based identity and persona persistence. The current contents of SOUL.md and User Persona are automatically injected into your context (in <current_soul_document> and <current_user_persona> tags), so you do not need to call readDocument to read them. Use readDocument only if you suspect the injected content may be stale.
4. Document tools are the only markdown persistence path.
5. Keep a working copy of each document in memory (seeded from the injected content), and merge new information into that copy before each updateDocument call.
6. SOUL.md (type: "soul") is for agent identity only: name, creature or nature, vibe, emoji, and the base template structure.
7. User Persona (type: "persona") is for user identity, role, work style, current context, interests, pain points, communication comfort level, and preferred input style.
8. Do not put user information into SOUL.md. Do not put agent identity into the persona document.
9. Document tools (readDocument and updateDocument) must ONLY be used for SOUL.md and User Persona documents. Never use them to create arbitrary content such as guides, tutorials, checklists, or reference materials. Present such content directly in your reply text instead.
10. Do not call saveUserQuestion with interests until you have spent at least 5-6 exchanges exploring the user's world in the discovery phase across multiple dimensions (workflow, pain points, goals, interests, AI expectations). The server enforces a minimum discovery exchange count early field saves will not advance the phase but will reduce conversation quality.
3. Use updateDocument for full-document rewrites and patchDocument for small edits. **Prefer patchDocument whenever only a few lines change** it is cheaper, safer, and less error-prone than resending the full document. Fall back to updateDocument only for structural rewrites that touch most of the file. The current contents of SOUL.md and User Persona are automatically injected into your context (in <current_soul_document> and <current_user_persona> tags), so you do not need to call readDocument to read them. Use readDocument only if you suspect the injected content may be stale.
4. patchDocument takes an ordered list of SEARCH/REPLACE hunks. Each search must match the current document byte-exact (whitespace, punctuation, casing); hunks are applied sequentially so later hunks see earlier results. If a hunk reports HUNK_NOT_FOUND, re-check the injected document against your search string; if HUNK_AMBIGUOUS, add surrounding context to make it unique (or pass replaceAll=true only when a global replace is intended).
5. Document tools are the only markdown persistence path.
6. Keep a working copy of each document in memory (seeded from the injected content), and merge new information into that copy before each updateDocument or patchDocument call.
7. SOUL.md (type: "soul") is for agent identity only: name, creature or nature, vibe, emoji, and the base template structure.
8. User Persona (type: "persona") is for user identity, role, work style, current context, interests, pain points, communication comfort level, and preferred input style.
9. Do not put user information into SOUL.md. Do not put agent identity into the persona document.
10. Document tools (readDocument, updateDocument, patchDocument) must ONLY be used for SOUL.md and User Persona documents. Never use them to create arbitrary content such as guides, tutorials, checklists, or reference materials. Present such content directly in your reply text instead.
11. Do not call saveUserQuestion with interests until you have spent at least 5-6 exchanges exploring the user's world in the discovery phase across multiple dimensions (workflow, pain points, goals, interests, AI expectations). The server enforces a minimum discovery exchange count early field saves will not advance the phase but will reduce conversation quality.
Workspace setup rules:
1. Do not create or modify workspace agents or agent groups unless the user explicitly asks for that setup.

View file

@ -1,9 +1,19 @@
import type { MarkdownPatchHunk } from '@lobechat/markdown-patch';
export const WebOnboardingIdentifier = 'lobe-web-onboarding';
export const WebOnboardingApiName = {
finishOnboarding: 'finishOnboarding',
getOnboardingState: 'getOnboardingState',
patchDocument: 'patchDocument',
readDocument: 'readDocument',
saveUserQuestion: 'saveUserQuestion',
updateDocument: 'updateDocument',
} as const;
export type WebOnboardingDocumentType = 'persona' | 'soul';
export interface PatchDocumentArgs {
hunks: MarkdownPatchHunk[];
type: WebOnboardingDocumentType;
}

View file

@ -21,6 +21,10 @@ import {
UserInteractionIdentifier,
UserInteractionInterventions,
} from '@lobechat/builtin-tool-user-interaction/client';
import {
WebOnboardingInterventions,
WebOnboardingManifest,
} from '@lobechat/builtin-tool-web-onboarding/client';
import { type BuiltinIntervention } from '@lobechat/types';
/**
@ -38,6 +42,7 @@ export const BuiltinToolInterventions: Record<string, Record<string, any>> = {
[MessageManifest.identifier]: MessageInterventions,
[NotebookManifest.identifier]: NotebookInterventions,
[UserInteractionIdentifier]: UserInteractionInterventions,
[WebOnboardingManifest.identifier]: WebOnboardingInterventions,
};
/**

View file

@ -37,30 +37,30 @@ export class OnboardingActionHintInjector extends BaseVirtualLastUserContentProv
const hints: string[] = [];
const phase = ctx.phaseGuidance;
// Detect empty documents and nudge tool calls
// Detect empty documents and nudge tool calls (empty docs use updateDocument; non-empty prefer patchDocument)
if (!ctx.soulContent) {
hints.push(
'SOUL.md is empty — call updateDocument(type="soul") to write the agent identity once the user gives you a name and emoji.',
'SOUL.md is empty — call updateDocument(type="soul") to write the initial agent identity once the user gives you a name and emoji.',
);
}
if (!ctx.personaContent) {
hints.push(
'User Persona is empty — call updateDocument(type="persona") to persist what you learn about the user.',
'User Persona is empty — call updateDocument(type="persona") to seed the initial persona once you have learned something about the user.',
);
}
// Phase-specific persistence reminders
if (phase.includes('Agent Identity')) {
hints.push(
'When the user settles on a name and emoji: call saveUserQuestion with agentName and agentEmoji, then call updateDocument(type="soul") to write SOUL.md.',
'When the user settles on a name and emoji: call saveUserQuestion with agentName and agentEmoji, then persist SOUL.md. If SOUL.md is already non-empty, prefer patchDocument(type="soul", hunks=[{search, replace}]) to amend only the changed lines; otherwise use updateDocument(type="soul").',
);
} else if (phase.includes('User Identity')) {
hints.push(
'When you learn the user\'s name: call saveUserQuestion with fullName, then call updateDocument(type="persona") to start the persona document.',
'When you learn the user\'s name: call saveUserQuestion with fullName, then persist the persona document. If User Persona is already non-empty, prefer patchDocument(type="persona", hunks=[{search, replace}]) to amend only the changed lines; otherwise use updateDocument(type="persona") to seed it.',
);
} else if (phase.includes('Discovery')) {
hints.push(
'Continue exploring. After sufficient discovery (5-6 exchanges), call saveUserQuestion with interests and responseLanguage. Update the persona document with updateDocument(type="persona") as you learn more.',
'Continue exploring. After sufficient discovery (5-6 exchanges), call saveUserQuestion with interests and responseLanguage. Then amend User Persona with patchDocument(type="persona", hunks=[{search, replace}]) — prefer small patches over full rewrites unless the document is still empty.',
);
hints.push(
'EARLY EXIT: If the user signals they want to finish (e.g., "好了", "谢谢", "行", "Done", asking for summary, or any completion signal), STOP exploring immediately. Save whatever fields you have (call saveUserQuestion with interests even if partial), present a brief summary, then call finishOnboarding. Do NOT continue asking questions after a completion signal.',
@ -72,7 +72,7 @@ export class OnboardingActionHintInjector extends BaseVirtualLastUserContentProv
}
hints.push(
'You MUST call the persistence tools (saveUserQuestion, updateDocument) to save information as you collect it. Simply acknowledging in conversation is NOT enough — data must be persisted via tool calls.',
'PERSISTENCE RULE: Call the persistence tools (saveUserQuestion, updateDocument, patchDocument) to save information as you collect it — simply acknowledging in conversation is NOT enough. For document writes, prefer patchDocument when the document already has content (smaller, safer edits); use updateDocument only for the first write or a full rewrite.',
);
hints.push(
'REMINDER: If the user says "好了", "谢谢", "行", "Done", "Thanks", or gives any completion signal at ANY phase, you MUST wrap up immediately and call finishOnboarding. This overrides all other phase rules.',

View file

@ -0,0 +1,12 @@
{
"name": "@lobechat/markdown-patch",
"version": "1.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"scripts": {
"test": "vitest"
}
}

View file

@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { applyMarkdownPatch } from './applyMarkdownPatch';
import { formatMarkdownPatchError } from './formatPatchError';
describe('applyMarkdownPatch', () => {
it('replaces a single unique hunk', () => {
const source = '# Title\n\nHello world\n';
const result = applyMarkdownPatch(source, [{ replace: 'Hello there', search: 'Hello world' }]);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.content).toBe('# Title\n\nHello there\n');
expect(result.applied).toBe(1);
}
});
it('applies multiple hunks sequentially where later hunks see earlier results', () => {
const source = 'alpha\nbeta\ngamma\n';
const result = applyMarkdownPatch(source, [
{ replace: 'ALPHA', search: 'alpha' },
{ replace: 'DELTA', search: 'ALPHA' },
]);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.content).toBe('DELTA\nbeta\ngamma\n');
expect(result.applied).toBe(2);
}
});
it('rejects a hunk not found', () => {
const source = 'one two three';
const result = applyMarkdownPatch(source, [{ replace: 'X', search: 'four' }]);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('HUNK_NOT_FOUND');
expect(result.error.hunkIndex).toBe(0);
}
});
it('rejects ambiguous hunks when replaceAll is not set', () => {
const source = 'foo bar foo';
const result = applyMarkdownPatch(source, [{ replace: 'baz', search: 'foo' }]);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('HUNK_AMBIGUOUS');
expect(result.error.occurrences).toBe(2);
}
});
it('replaces all occurrences when replaceAll=true', () => {
const source = 'foo bar foo baz foo';
const result = applyMarkdownPatch(source, [
{ replace: 'qux', replaceAll: true, search: 'foo' },
]);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.content).toBe('qux bar qux baz qux');
expect(result.applied).toBe(3);
}
});
it('rejects empty hunks array', () => {
const result = applyMarkdownPatch('doc', []);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.code).toBe('EMPTY_HUNKS');
});
it('rejects empty search', () => {
const result = applyMarkdownPatch('doc', [{ replace: 'x', search: '' }]);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('EMPTY_SEARCH');
expect(result.error.hunkIndex).toBe(0);
}
});
it('aborts on first failing hunk without applying later ones', () => {
const source = 'keep me';
const result = applyMarkdownPatch(source, [
{ replace: 'X', search: 'nope' },
{ replace: 'changed', search: 'keep me' },
]);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.hunkIndex).toBe(0);
});
it('preserves byte-exact whitespace differences (strict by design)', () => {
const source = '- item one\n- item two\n';
const result = applyMarkdownPatch(source, [{ replace: '- item alpha', search: '- item one' }]);
expect(result.ok).toBe(false);
});
it('supports multi-line search and replace blocks', () => {
const source = '## A\ntext\n\n## B\nmore\n';
const result = applyMarkdownPatch(source, [
{ replace: '## A\ntext\nnew line\n', search: '## A\ntext\n' },
]);
expect(result.ok).toBe(true);
if (result.ok) expect(result.content).toBe('## A\ntext\nnew line\n\n## B\nmore\n');
});
});
describe('formatMarkdownPatchError', () => {
it('formats HUNK_NOT_FOUND with index hint', () => {
const msg = formatMarkdownPatchError({
code: 'HUNK_NOT_FOUND',
hunkIndex: 2,
search: 'abc',
});
expect(msg).toMatch(/Hunk #2/);
expect(msg).toMatch(/byte-exact/);
});
it('formats HUNK_AMBIGUOUS with occurrence count', () => {
const msg = formatMarkdownPatchError({
code: 'HUNK_AMBIGUOUS',
hunkIndex: 0,
occurrences: 3,
});
expect(msg).toMatch(/matches 3 locations/);
expect(msg).toMatch(/replaceAll=true/);
});
it('formats EMPTY_SEARCH', () => {
const msg = formatMarkdownPatchError({ code: 'EMPTY_SEARCH', hunkIndex: 1 });
expect(msg).toMatch(/Hunk #1/);
expect(msg).toMatch(/empty search/);
});
it('formats EMPTY_HUNKS', () => {
const msg = formatMarkdownPatchError({ code: 'EMPTY_HUNKS', hunkIndex: -1 });
expect(msg).toMatch(/No hunks/);
});
});

View file

@ -0,0 +1,68 @@
import type { MarkdownPatchHunk, MarkdownPatchResult } from './types';
const countOccurrences = (source: string, needle: string): number => {
if (!needle) return 0;
let count = 0;
let from = 0;
while (true) {
const idx = source.indexOf(needle, from);
if (idx === -1) break;
count += 1;
from = idx + needle.length;
}
return count;
};
/**
* Apply a list of byte-exact SEARCH/REPLACE hunks to a markdown document.
*
* Semantics:
* - Each hunk's `search` must appear verbatim in the current document.
* - Whitespace, punctuation, casing differences are not tolerated.
* - If `search` appears multiple times, caller must set `replaceAll: true`,
* otherwise the hunk is rejected as ambiguous.
* - Hunks are applied sequentially; later hunks see earlier results.
* - First error aborts the whole patch; no partial application is committed.
*/
export const applyMarkdownPatch = (
source: string,
hunks: MarkdownPatchHunk[],
): MarkdownPatchResult => {
if (!Array.isArray(hunks) || hunks.length === 0) {
return { error: { code: 'EMPTY_HUNKS', hunkIndex: -1 }, ok: false };
}
let current = source;
let applied = 0;
for (const [hunkIndex, hunk] of hunks.entries()) {
if (!hunk.search) {
return { error: { code: 'EMPTY_SEARCH', hunkIndex }, ok: false };
}
const occurrences = countOccurrences(current, hunk.search);
if (occurrences === 0) {
return {
error: { code: 'HUNK_NOT_FOUND', hunkIndex, search: hunk.search },
ok: false,
};
}
if (occurrences > 1 && !hunk.replaceAll) {
return {
error: { code: 'HUNK_AMBIGUOUS', hunkIndex, occurrences },
ok: false,
};
}
current = hunk.replaceAll
? current.split(hunk.search).join(hunk.replace)
: current.replace(hunk.search, hunk.replace);
applied += hunk.replaceAll ? occurrences : 1;
}
return { applied, content: current, ok: true };
};

View file

@ -0,0 +1,20 @@
import type { MarkdownPatchErrorDetail } from './types';
export const formatMarkdownPatchError = (error: MarkdownPatchErrorDetail): string => {
const idx = error.hunkIndex;
switch (error.code) {
case 'EMPTY_HUNKS': {
return 'No hunks provided. Include at least one { search, replace } entry.';
}
case 'EMPTY_SEARCH': {
return `Hunk #${idx} has empty search. Provide a non-empty substring to locate.`;
}
case 'HUNK_NOT_FOUND': {
return `Hunk #${idx} search not found. Ensure the search string matches the current document byte-exact (whitespace, punctuation, casing). Re-read the document if unsure.`;
}
case 'HUNK_AMBIGUOUS': {
const n = error.occurrences ?? 0;
return `Hunk #${idx} search matches ${n} locations. Add surrounding context to uniquify, or set replaceAll=true to replace every occurrence.`;
}
}
};

View file

@ -0,0 +1,10 @@
export { applyMarkdownPatch } from './applyMarkdownPatch';
export { formatMarkdownPatchError } from './formatPatchError';
export type {
MarkdownPatchErrorCode,
MarkdownPatchErrorDetail,
MarkdownPatchFailure,
MarkdownPatchHunk,
MarkdownPatchResult,
MarkdownPatchSuccess,
} from './types';

View file

@ -0,0 +1,31 @@
export interface MarkdownPatchHunk {
replace: string;
replaceAll?: boolean;
search: string;
}
export interface MarkdownPatchSuccess {
applied: number;
content: string;
ok: true;
}
export type MarkdownPatchErrorCode =
| 'EMPTY_HUNKS'
| 'EMPTY_SEARCH'
| 'HUNK_AMBIGUOUS'
| 'HUNK_NOT_FOUND';
export interface MarkdownPatchErrorDetail {
code: MarkdownPatchErrorCode;
hunkIndex: number;
occurrences?: number;
search?: string;
}
export interface MarkdownPatchFailure {
error: MarkdownPatchErrorDetail;
ok: false;
}
export type MarkdownPatchResult = MarkdownPatchFailure | MarkdownPatchSuccess;

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});

View file

@ -40,6 +40,12 @@ export interface ChatTopicBotContext {
platformThreadId: string;
}
export interface OnboardingFeedbackEntry {
comment?: string;
rating: 'good' | 'bad';
submittedAt: string;
}
export interface ChatTopicMetadata {
bot?: ChatTopicBotContext;
boundDeviceId?: string;
@ -54,6 +60,11 @@ export interface ChatTopicMetadata {
*/
cronJobId?: string;
model?: string;
/**
* Free-form feedback collected after agent onboarding completion.
* Comment text is stored only here (not analytics) and is length-capped server-side.
*/
onboardingFeedback?: OnboardingFeedbackEntry;
provider?: string;
/**
* Currently running Gateway operation on this topic.

View file

@ -75,7 +75,7 @@ const FeedbackModal = memo<FeedbackModalProps>(({ initialValues, onClose, open }
const values = await form.validateFields();
setLoading(true);
const response = await lambdaClient.market.submitFeedback.mutate({
await lambdaClient.market.submitFeedback.mutate({
clientInfo: {
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,

View file

@ -4,14 +4,17 @@ import { HeartFilled } from '@ant-design/icons';
import { ActionIcon, Button, Flexbox } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { X } from 'lucide-react';
import { type ReactNode } from 'react';
import type { HTMLAttributeAnchorTarget, ReactNode } from 'react';
import { memo } from 'react';
export interface HighlightNotificationProps {
actionHref?: string;
actionIcon?: ReactNode;
actionLabel?: ReactNode;
actionTarget?: HTMLAttributeAnchorTarget;
description?: ReactNode;
image?: string;
onAction?: () => void;
onActionClick?: () => void;
onClose?: () => void;
open?: boolean;
@ -24,6 +27,14 @@ const styles = createStaticStyles(({ css }) => ({
width: 100%;
margin-block-start: 8px;
`,
actionContent: css`
display: inline-flex;
gap: 8px;
align-items: center;
justify-content: center;
width: 100%;
`,
card: css`
position: fixed;
z-index: 1000;
@ -63,9 +74,28 @@ const styles = createStaticStyles(({ css }) => ({
}));
const HighlightNotification = memo<HighlightNotificationProps>(
({ open, onClose, onActionClick, image, title, description, actionLabel, actionHref }) => {
({
actionHref,
actionIcon = <HeartFilled />,
actionLabel,
actionTarget = '_blank',
description,
image,
onAction,
onActionClick,
onClose,
open,
title,
}) => {
if (!open) return null;
const actionContent = actionLabel ? (
<span className={styles.actionContent}>
{actionIcon && <span>{actionIcon}</span>}
<span>{actionLabel}</span>
</span>
) : null;
return (
<Flexbox className={styles.card}>
<ActionIcon className={styles.closeButton} icon={X} size={14} onClick={onClose} />
@ -74,19 +104,30 @@ const HighlightNotification = memo<HighlightNotificationProps>(
<Flexbox gap={4} padding={12}>
{title && <div className={styles.title}>{title}</div>}
{description && <div className={styles.description}>{description}</div>}
{actionLabel && (
{actionLabel && actionHref && (
<a
className={styles.action}
href={actionHref || '/'}
href={actionHref}
rel="noopener noreferrer"
target="_blank"
target={actionTarget}
onClick={onActionClick}
>
<Button block icon={HeartFilled} size="small" type="primary">
{actionLabel}
<Button block size="small" type="primary">
{actionContent}
</Button>
</a>
)}
{actionLabel && !actionHref && (
<Button
block
className={styles.action}
size="small"
type="primary"
onClick={onAction}
>
{actionContent}
</Button>
)}
</Flexbox>
</Flexbox>
</Flexbox>

View file

@ -107,6 +107,7 @@ describe('mapFeatureFlagsEnvToState', () => {
welcome_suggest: true,
knowledge_base: false,
rag_eval: true,
agent_onboarding: true,
market: true,
speech_to_text: true,
changelog: false,
@ -130,6 +131,7 @@ describe('mapFeatureFlagsEnvToState', () => {
showWelcomeSuggest: true,
enableKnowledgeBase: false,
enableRAGEval: true,
enableAgentOnboarding: true,
showMarket: true,
enableSTT: true,
showCloudPromotion: true,
@ -142,6 +144,7 @@ describe('mapFeatureFlagsEnvToState', () => {
const userId = 'user-123';
const config = {
edit_agent: ['user-123', 'user-456'],
agent_onboarding: ['user-123'],
create_session: ['user-789'],
dalle: true,
knowledge_base: ['user-123'],
@ -151,6 +154,7 @@ describe('mapFeatureFlagsEnvToState', () => {
expect(mappedState.isAgentEditable).toBe(true); // user-123 is in allowlist
expect(mappedState.enableAgentOnboarding).toBe(true); // user-123 is in allowlist
expect(mappedState.enableKnowledgeBase).toBe(true); // user-123 is in allowlist
});
@ -169,12 +173,14 @@ describe('mapFeatureFlagsEnvToState', () => {
it('should return false for array flags when no user ID provided', () => {
const config = {
agent_onboarding: ['user-1'],
edit_agent: ['user-123', 'user-456'],
create_session: true,
};
const mappedState = mapFeatureFlagsEnvToState(config);
expect(mappedState.enableAgentOnboarding).toBe(false);
expect(mappedState.isAgentEditable).toBe(false);
});
@ -182,6 +188,7 @@ describe('mapFeatureFlagsEnvToState', () => {
const userId = 'user-123';
const config = {
edit_agent: ['user-123'],
agent_onboarding: ['user-123'],
create_session: true,
dalle: false,
ai_image: ['user-456'],
@ -193,6 +200,7 @@ describe('mapFeatureFlagsEnvToState', () => {
expect(mappedState.isAgentEditable).toBe(true);
expect(mappedState.enableAgentOnboarding).toBe(true);
expect(mappedState.showAiImage).toBe(false);
expect(mappedState.enableKnowledgeBase).toBe(true);
expect(mappedState.enableRAGEval).toBe(true);

View file

@ -2,6 +2,7 @@ import { z } from 'zod';
// Define a union type for feature flag values: either boolean or array of user IDs
const FeatureFlagValue = z.union([z.boolean(), z.array(z.string())]);
const isDev = process.env.NODE_ENV === 'development';
export const FeatureFlagsSchema = z.object({
check_updates: FeatureFlagValue.optional(),
@ -29,6 +30,7 @@ export const FeatureFlagsSchema = z.object({
rag_eval: FeatureFlagValue.optional(),
// internal flag
agent_onboarding: FeatureFlagValue.optional(),
cloud_promotion: FeatureFlagValue.optional(),
// the flags below can only be used with commercial license
@ -75,6 +77,7 @@ export const DEFAULT_FEATURE_FLAGS: IFeatureFlags = {
knowledge_base: true,
rag_eval: false,
agent_onboarding: isDev,
cloud_promotion: false,
market: true,
@ -106,6 +109,7 @@ export const mapFeatureFlagsEnvToState = (config: IFeatureFlags, userId?: string
enableKnowledgeBase: evaluateFeatureFlag(config.knowledge_base, userId),
enableRAGEval: evaluateFeatureFlag(config.rag_eval, userId),
enableAgentOnboarding: evaluateFeatureFlag(config.agent_onboarding, userId),
showCloudPromotion: evaluateFeatureFlag(config.cloud_promotion, userId),

View file

@ -0,0 +1,59 @@
/**
* @vitest-environment happy-dom
*/
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@lobehub/ui', () => ({
Avatar: ({ avatar }: { avatar: string }) => <div>{avatar}</div>,
Flexbox: ({ children }: { children?: ReactNode; [key: string]: unknown }) => (
<div>{children}</div>
),
Text: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<span {...props}>{children}</span>
),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) =>
(
({
'tool.intervention.onboarding.agentIdentity.applyHint':
'The new identity will appear after approval.',
'tool.intervention.onboarding.agentIdentity.description':
'Approving this change updates the Agent shown in Inbox and in this onboarding conversation.',
'tool.intervention.onboarding.agentIdentity.emoji': 'Agent avatar',
'tool.intervention.onboarding.agentIdentity.eyebrow': 'Onboarding approval',
'tool.intervention.onboarding.agentIdentity.name': 'Agent name',
'tool.intervention.onboarding.agentIdentity.targetInbox': 'Inbox Agent',
'tool.intervention.onboarding.agentIdentity.targetOnboarding': 'Current onboarding Agent',
'tool.intervention.onboarding.agentIdentity.targets': 'Applies to',
'tool.intervention.onboarding.agentIdentity.title': 'Confirm Agent identity update',
'untitledAgent': 'Untitled Agent',
}) satisfies Record<string, string>
)[key] || key,
}),
}));
describe('web onboarding intervention registry', () => {
it('renders the custom agent identity approval card for saveUserQuestion', async () => {
const { WebOnboardingInterventions } =
await import('@lobechat/builtin-tool-web-onboarding/client');
const { WebOnboardingApiName } = await import('@lobechat/builtin-tool-web-onboarding');
const Component = WebOnboardingInterventions[WebOnboardingApiName.saveUserQuestion];
expect(Component).toBeDefined();
if (!Component) throw new TypeError('Expected web onboarding intervention to be registered');
render(<Component args={{ agentEmoji: '🛰️', agentName: 'Atlas' }} messageId="message-1" />);
expect(screen.getByText('Confirm Agent identity update')).toBeInTheDocument();
expect(screen.getAllByText('Atlas')).toHaveLength(2);
expect(screen.getAllByText('🛰️')).toHaveLength(2);
expect(screen.getByText('Inbox Agent')).toBeInTheDocument();
expect(screen.getByText('Current onboarding Agent')).toBeInTheDocument();
});
});

View file

@ -4,7 +4,6 @@ import { memo, useCallback } from 'react';
import SafeBoundary from '@/components/ErrorBoundary';
import { LOADING_FLAT } from '@/const/message';
import { useErrorContent } from '@/features/Conversation/Error';
import { type AssistantContentBlock } from '@/types/index';
import ErrorContent from '../../../ChatItem/components/ErrorContent';
import { messageStateSelectors, useConversationStore } from '../../../store';
@ -12,13 +11,14 @@ import ImageFileListViewer from '../../components/ImageFileListViewer';
import Reasoning from '../../components/Reasoning';
import { Tools } from '../Tools';
import MessageContent from './MessageContent';
import type { RenderableAssistantContentBlock } from './types';
interface ContentBlockProps extends AssistantContentBlock {
interface ContentBlockProps extends RenderableAssistantContentBlock {
assistantId: string;
disableEditing?: boolean;
}
const ContentBlock = memo<ContentBlockProps>(
({ id, tools, content, imageList, reasoning, error, assistantId, disableEditing }) => {
({ id, tools, content, imageList, reasoning, error, domId, assistantId, disableEditing }) => {
const errorContent = useErrorContent(error);
const showImageItems = !!imageList && imageList.length > 0;
const [isReasoning, deleteMessage, continueGeneration] = useConversationStore((s) => [
@ -35,7 +35,7 @@ const ContentBlock = memo<ContentBlockProps>(
const handleRegenerate = useCallback(async () => {
await deleteMessage(id);
continueGeneration(assistantId);
}, [id]);
}, [assistantId, continueGeneration, deleteMessage, id]);
if (error && (content === LOADING_FLAT || !content)) {
return (
@ -64,7 +64,7 @@ const ContentBlock = memo<ContentBlockProps>(
}
return (
<Flexbox gap={8} id={id}>
<Flexbox gap={8} id={domId ?? id}>
{showReasoning && (
<SafeBoundary>
<Reasoning {...reasoning} id={id} />

View file

@ -5,10 +5,9 @@ import { Flexbox, ScrollShadow } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo, type RefObject, useMemo } from 'react';
import type { AssistantContentBlock } from '@/types/index';
import { resolveAssistantGroupFromMessages } from '../utils/resolveAssistantGroupFromMessages';
import ContentBlock from './ContentBlock';
import type { RenderableAssistantContentBlock } from './types';
const styles = createStaticStyles(({ css }) => ({
scrollTask: css`
@ -29,7 +28,7 @@ interface ContentBlocksScrollBaseProps {
interface ContentBlocksScrollFromBlocks extends ContentBlocksScrollBaseProps {
assistantId: string;
blocks: AssistantContentBlock[];
blocks: RenderableAssistantContentBlock[];
messages?: never;
}
@ -50,7 +49,10 @@ const ContentBlocksScroll = memo<ContentBlocksScrollProps>((props) => {
const assistantIdFromProps = 'messages' in props ? undefined : props.assistantId;
const blocksFromProps = 'messages' in props ? undefined : props.blocks;
const { assistantId, blocks } = useMemo(() => {
const { assistantId, blocks } = useMemo<{
assistantId: string;
blocks: RenderableAssistantContentBlock[];
}>(() => {
if (messagesList !== undefined) {
return resolveAssistantGroupFromMessages(messagesList);
}
@ -64,7 +66,7 @@ const ContentBlocksScroll = memo<ContentBlocksScrollProps>((props) => {
<Flexbox gap={8}>
{blocks.map((block) => (
<ContentBlock
key={block.id}
key={block.renderKey ?? block.id}
{...block}
assistantId={assistantId}
disableEditing={disableEditing}

View file

@ -0,0 +1,163 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AssistantContentBlock } from '@/types/index';
import Group from './Group';
let mockIsCollapsed = false;
let mockIsGenerating = false;
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
vi.mock('antd-style', () => ({
createStaticStyles: () => ({
container: 'group-container',
}),
}));
vi.mock('../../../store', () => ({
messageStateSelectors: {
isMessageCollapsed: () => () => mockIsCollapsed,
isMessageGenerating: () => () => mockIsGenerating,
},
useConversationStore: (selector: (state: unknown) => unknown) => selector({}),
}));
vi.mock('./CollapsedMessage', () => ({
CollapsedMessage: ({ content }: { content?: string }) => <div>{content}</div>,
}));
vi.mock('./WorkflowCollapse', () => ({
default: ({
blocks,
}: {
blocks: Array<{ content: string; domId?: string; tools?: unknown[] }>;
}) => (
<div
data-testid="workflow-segment"
data-blocks={JSON.stringify(
blocks.map(({ content, domId, tools }) => ({
content,
domId,
toolCount: tools?.length ?? 0,
})),
)}
/>
),
}));
vi.mock('./GroupItem', () => ({
default: ({
content,
domId,
id,
isFirstBlock,
tools,
}: {
content: string;
domId?: string;
id: string;
isFirstBlock?: boolean;
tools?: unknown[];
}) => (
<div
data-testid="answer-segment"
data-block={JSON.stringify({
content,
domId,
id,
isFirstBlock: !!isFirstBlock,
toolCount: tools?.length ?? 0,
})}
/>
),
}));
const blk = (p: Partial<AssistantContentBlock> & { id: string }): AssistantContentBlock =>
({ content: '', ...p }) as AssistantContentBlock;
const parseAnswerSegment = () =>
JSON.parse(screen.getByTestId('answer-segment').getAttribute('data-block') || '{}');
const parseWorkflowSegment = () =>
JSON.parse(screen.getByTestId('workflow-segment').getAttribute('data-blocks') || '[]');
describe('Group', () => {
afterEach(() => {
cleanup();
mockIsCollapsed = false;
mockIsGenerating = false;
});
it('keeps long structured mixed content visible and moves only tools into workflow', () => {
const longContent =
'后宫番 + 实际项目中的状态管理问题,这个组合挺有意思的!\n\n对于实际项目中的状态管理你目前遇到的具体问题是什么比如\n- 不知道什么时候该用 useState什么时候该用 Context\n- 组件间状态传递变得混乱\n- 性能问题(不必要的重渲染)';
const { container } = render(
<Group
id="assistant-1"
messageIndex={0}
blocks={[
blk({
content: longContent,
id: 'block-1',
tools: [{ apiName: 'search', id: 'tool-1' } as any],
}),
]}
/>,
);
const sequence = Array.from(container.querySelectorAll('[data-testid]')).map((node) =>
node.getAttribute('data-testid'),
);
expect(sequence).toEqual(['answer-segment', 'workflow-segment']);
expect(parseAnswerSegment()).toEqual({
content: longContent,
domId: 'block-1__answer',
id: 'block-1',
isFirstBlock: false,
toolCount: 0,
});
expect(parseWorkflowSegment()).toEqual([
{
content: '',
domId: 'block-1__workflow',
toolCount: 1,
},
]);
});
it('keeps short mixed status text inside workflow', () => {
render(
<Group
id="assistant-1"
messageIndex={0}
blocks={[
blk({
content: '现在我来搜索资料。',
id: 'block-1',
tools: [{ apiName: 'search', id: 'tool-1' } as any],
}),
]}
/>,
);
expect(screen.queryByTestId('answer-segment')).not.toBeInTheDocument();
expect(parseWorkflowSegment()).toEqual([
{
content: '现在我来搜索资料。',
domId: undefined,
toolCount: 1,
},
]);
});
});

View file

@ -8,9 +8,15 @@ import { type AssistantContentBlock } from '@/types/index';
import { messageStateSelectors, useConversationStore } from '../../../store';
import { MessageAggregationContext } from '../../Contexts/MessageAggregationContext';
import { areWorkflowToolsComplete, getPostToolAnswerSplitIndex } from '../toolDisplayNames';
import { POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD } from '../constants';
import {
areWorkflowToolsComplete,
getPostToolAnswerSplitIndex,
scorePostToolBlockAsFinalAnswer,
} from '../toolDisplayNames';
import { CollapsedMessage } from './CollapsedMessage';
import GroupItem from './GroupItem';
import type { RenderableAssistantContentBlock } from './types';
import WorkflowCollapse from './WorkflowCollapse';
const styles = createStaticStyles(({ css }) => {
@ -33,14 +39,28 @@ interface GroupChildrenProps {
messageIndex: number;
}
interface PartitionedBlocks {
answerBlocks: AssistantContentBlock[];
/** True while generating if long post-tool answer was moved outside the fold (tool phase UI may show “done”). */
postToolTailPromoted: boolean;
workingBlocks: AssistantContentBlock[];
interface AnswerSegment {
block: RenderableAssistantContentBlock;
kind: 'answer';
}
const isEmptyBlock = (block: AssistantContentBlock) =>
interface WorkflowSegment {
blocks: RenderableAssistantContentBlock[];
kind: 'workflow';
}
type GroupRenderSegment = AnswerSegment | WorkflowSegment;
interface PartitionedBlocks {
/** True while generating if long post-tool answer was moved outside the fold (tool phase UI may show “done”). */
postToolTailPromoted: boolean;
segments: GroupRenderSegment[];
}
const ANSWER_DOM_ID_SUFFIX = '__answer';
const WORKFLOW_DOM_ID_SUFFIX = '__workflow';
const isEmptyBlock = (block: RenderableAssistantContentBlock) =>
(!block.content || block.content === LOADING_FLAT) &&
(!block.tools || block.tools.length === 0) &&
!block.error &&
@ -66,44 +86,126 @@ const isTrailingReasoningCandidate = (block: AssistantContentBlock): boolean =>
return hasReasoningContent(block) && !hasTools(block) && !block.error;
};
const splitPostToolBlocks = (
postBlocks: AssistantContentBlock[],
): Pick<PartitionedBlocks, 'answerBlocks' | 'workingBlocks'> => {
const answerBlocks: AssistantContentBlock[] = [];
const workingBlocks: AssistantContentBlock[] = [];
const createAnswerRenderBlock = (
block: AssistantContentBlock,
overrides: Partial<RenderableAssistantContentBlock> = {},
): RenderableAssistantContentBlock => {
return {
...block,
domId: `${block.id}${ANSWER_DOM_ID_SUFFIX}`,
renderKey: `${block.id}${ANSWER_DOM_ID_SUFFIX}`,
...overrides,
};
};
const createWorkflowRenderBlock = (
block: AssistantContentBlock,
overrides: Partial<RenderableAssistantContentBlock> = {},
): RenderableAssistantContentBlock => {
return {
...block,
domId: `${block.id}${WORKFLOW_DOM_ID_SUFFIX}`,
renderKey: `${block.id}${WORKFLOW_DOM_ID_SUFFIX}`,
...overrides,
};
};
const appendAnswerBlock = (
segments: GroupRenderSegment[],
block: RenderableAssistantContentBlock,
) => {
segments.push({ block, kind: 'answer' });
};
const appendWorkflowBlock = (
segments: GroupRenderSegment[],
block: RenderableAssistantContentBlock,
) => {
const lastSegment = segments.at(-1);
if (lastSegment?.kind === 'workflow') {
lastSegment.blocks.push(block);
return;
}
segments.push({ blocks: [block], kind: 'workflow' });
};
const shouldPromoteMixedBlockContent = (block: AssistantContentBlock): boolean => {
if (!hasTools(block) || !hasSubstantiveContent(block)) return false;
return (
scorePostToolBlockAsFinalAnswer({ ...block, tools: undefined }) >=
POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD
);
};
const appendWorkflowRangeBlock = (segments: GroupRenderSegment[], block: AssistantContentBlock) => {
if (!shouldPromoteMixedBlockContent(block)) {
appendWorkflowBlock(segments, block);
return;
}
appendAnswerBlock(
segments,
createAnswerRenderBlock(block, {
error: undefined,
reasoning: undefined,
tools: undefined,
}),
);
appendWorkflowBlock(
segments,
createWorkflowRenderBlock(block, {
content: '',
imageList: undefined,
}),
);
};
const appendPostToolBlocks = (
segments: GroupRenderSegment[],
postBlocks: AssistantContentBlock[],
) => {
let index = 0;
while (index < postBlocks.length) {
const block = postBlocks[index]!;
if (!isTrailingReasoningCandidate(block)) break;
workingBlocks.push({ ...block, content: '' });
appendWorkflowBlock(
segments,
createWorkflowRenderBlock(block, {
content: '',
}),
);
if (hasSubstantiveContent(block) || (block.imageList?.length ?? 0) > 0) {
answerBlocks.push({ ...block, reasoning: undefined });
appendAnswerBlock(
segments,
createAnswerRenderBlock(block, {
reasoning: undefined,
}),
);
}
index += 1;
}
answerBlocks.push(...postBlocks.slice(index));
return { answerBlocks, workingBlocks };
for (const block of postBlocks.slice(index)) {
appendAnswerBlock(segments, block);
}
};
/**
* Partition blocks into "working phase" and "answer phase".
*
* Working phase: from first block with tools through last block with tools
* (inclusive interleaved content/reasoning blocks between tool blocks are included).
*
* Answer phase: blocks before the first tool block, plus blocks after the last tool
* (or after detected post-tool final answer while still generating).
* Partition blocks into ordered render segments. Workflow segments stay collapsible; answer
* segments render inline so long prose can remain visible even when tools are present nearby.
*/
const partitionBlocks = (
blocks: AssistantContentBlock[],
isGenerating: boolean,
): PartitionedBlocks => {
const segments: GroupRenderSegment[] = [];
let lastToolIndex = -1;
for (let i = blocks.length - 1; i >= 0; i--) {
if (hasTools(blocks[i])) {
@ -113,7 +215,11 @@ const partitionBlocks = (
}
if (lastToolIndex === -1) {
return { answerBlocks: blocks, postToolTailPromoted: false, workingBlocks: [] };
for (const block of blocks) {
appendAnswerBlock(segments, block);
}
return { postToolTailPromoted: false, segments };
}
let firstToolIndex = 0;
@ -124,7 +230,9 @@ const partitionBlocks = (
}
}
const preBlocks = blocks.slice(0, firstToolIndex);
for (const block of blocks.slice(0, firstToolIndex)) {
appendAnswerBlock(segments, block);
}
if (isGenerating) {
const toolsFlat = blocks.flatMap((b) => b.tools ?? []);
@ -139,24 +247,29 @@ const partitionBlocks = (
}
}
for (const block of blocks.slice(firstToolIndex, workingEndExclusive)) {
appendWorkflowRangeBlock(segments, block);
}
for (const block of blocks.slice(workingEndExclusive)) {
appendAnswerBlock(segments, block);
}
return {
answerBlocks: [...preBlocks, ...blocks.slice(workingEndExclusive)],
postToolTailPromoted,
workingBlocks: blocks.slice(firstToolIndex, workingEndExclusive),
segments,
};
}
const postBlocks = blocks.slice(lastToolIndex + 1);
const postToolReasoning = splitPostToolBlocks(postBlocks);
const workingBlocks = [
...blocks.slice(firstToolIndex, lastToolIndex + 1),
...postToolReasoning.workingBlocks,
];
for (const block of blocks.slice(firstToolIndex, lastToolIndex + 1)) {
appendWorkflowRangeBlock(segments, block);
}
appendPostToolBlocks(segments, blocks.slice(lastToolIndex + 1));
return {
answerBlocks: [...preBlocks, ...postToolReasoning.answerBlocks],
postToolTailPromoted: false,
workingBlocks,
segments,
};
};
@ -168,7 +281,7 @@ const Group = memo<GroupChildrenProps>(
]);
const contextValue = useMemo(() => ({ assistantGroupId: id }), [id]);
const { workingBlocks, answerBlocks, postToolTailPromoted } = useMemo(
const { segments, postToolTailPromoted } = useMemo(
() => partitionBlocks(blocks, isGenerating),
[blocks, isGenerating],
);
@ -188,16 +301,23 @@ const Group = memo<GroupChildrenProps>(
return (
<MessageAggregationContext value={contextValue}>
<Flexbox className={styles.container} gap={8}>
{workingBlocks.length > 0 && (
<WorkflowCollapse
assistantMessageId={id}
blocks={workingBlocks}
defaultStreamingExpanded={defaultWorkflowExpanded}
disableEditing={disableEditing}
workflowChromeComplete={workflowChromeComplete}
/>
)}
{answerBlocks.map((item) => {
{segments.map((segment, index) => {
if (segment.kind === 'workflow') {
if (segment.blocks.length === 0) return null;
return (
<WorkflowCollapse
assistantMessageId={id}
blocks={segment.blocks}
defaultStreamingExpanded={defaultWorkflowExpanded}
disableEditing={disableEditing}
key={segment.blocks[0]?.renderKey ?? `${id}.workflow.${index}`}
workflowChromeComplete={workflowChromeComplete}
/>
);
}
const item = segment.block;
if (!isGenerating && isEmptyBlock(item)) return null;
return (
@ -206,7 +326,7 @@ const Group = memo<GroupChildrenProps>(
assistantId={id}
contentId={contentId}
disableEditing={disableEditing}
key={id + '.' + item.id}
key={item.renderKey ?? `${id}.${item.id}.${index}`}
messageIndex={messageIndex}
/>
);

View file

@ -2,12 +2,11 @@ import { Flexbox } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { type AssistantContentBlock } from '@/types/index';
import { useConversationStore } from '../../../store';
import ContentBlock from './ContentBlock';
import type { RenderableAssistantContentBlock } from './types';
interface GroupItemProps extends AssistantContentBlock {
interface GroupItemProps extends RenderableAssistantContentBlock {
assistantId: string;
contentId?: string;
disableEditing?: boolean;

View file

@ -9,7 +9,6 @@ import { useTranslation } from 'react-i18next';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import { useAutoScroll } from '@/hooks/useAutoScroll';
import { shinyTextStyles } from '@/styles';
import { type AssistantContentBlock } from '@/types/index';
import { messageStateSelectors, useConversationStore } from '../../../store';
import {
@ -29,19 +28,20 @@ import {
hasToolError,
shapeProseForWorkflowHeadline,
} from '../toolDisplayNames';
import type { RenderableAssistantContentBlock } from './types';
import WorkflowExpandedList from './WorkflowExpandedList';
interface WorkflowCollapseProps {
/** Assistant group message id (for generation state) */
assistantMessageId: string;
blocks: AssistantContentBlock[];
blocks: RenderableAssistantContentBlock[];
/** Default expansion state while the workflow is still streaming. Pending intervention always expands. */
defaultStreamingExpanded?: boolean;
disableEditing?: boolean;
workflowChromeComplete?: boolean;
}
const collectTools = (blocks: AssistantContentBlock[]): ChatToolPayloadWithResult[] => {
const collectTools = (blocks: RenderableAssistantContentBlock[]): ChatToolPayloadWithResult[] => {
return blocks.flatMap((b) => b.tools ?? []);
};

View file

@ -1,12 +1,11 @@
import { memo, type RefObject } from 'react';
import type { AssistantContentBlock } from '@/types/index';
import ContentBlocksScroll from './ContentBlocksScroll';
import type { RenderableAssistantContentBlock } from './types';
interface WorkflowExpandedListProps {
assistantId: string;
blocks: AssistantContentBlock[];
blocks: RenderableAssistantContentBlock[];
constrained?: boolean;
disableEditing?: boolean;
onScroll?: () => void;

View file

@ -0,0 +1,6 @@
import type { AssistantContentBlock } from '@/types/index';
export interface RenderableAssistantContentBlock extends AssistantContentBlock {
domId?: string;
renderKey?: string;
}

View file

@ -62,7 +62,7 @@ export const TOOL_HEADLINE_TRUNCATION_SUFFIX = '...';
// ─── Post-tool “final answer” block promotion (Group partition) ───────────
/** Sum of heuristic scores at or above this moves blocks after last tool into answer column while generating. */
/** Sum of heuristic scores at or above this promotes visible prose out of workflow chrome. */
export const POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD = 3;
/** Add this score when compacted prose length ≥ this (long answer signal). */

View file

@ -6,6 +6,7 @@ import { POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD } from './constants';
import {
getPostToolAnswerSplitIndex,
getWorkflowStreamingHeadlineState,
scoreBlockContentAsAnswerLike,
scorePostToolBlockAsFinalAnswer,
shapeProseForWorkflowHeadline,
} from './toolDisplayNames';
@ -33,6 +34,19 @@ describe('shapeProseForWorkflowHeadline', () => {
});
describe('post-tool final answer split', () => {
it('scores long structured content as answer-like even when tools share the block', () => {
const score = scoreBlockContentAsAnswerLike(
blk({
id: 'mixed',
content:
'先总结当前结论。\n\n## 下一步\n\n- 对比方案 A\n- 对比方案 B\n- 给出推荐与风险说明。',
tools: [{ apiName: 'search', id: 't1' } as any],
}),
);
expect(score).toBeGreaterThanOrEqual(POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD);
});
it('returns split index for long structured prose-only block after last tool', () => {
const long =
'Direct summary - Node.js 24 (released May 6, 2025) is a major platform update that upgrades V8 to a newer track, ships notable HTTP and fetch-related changes, and introduces practical migration items for native addons and tooling.\n\n## Checklist\n\n- Rebuild native modules';

View file

@ -37,9 +37,8 @@ export const areWorkflowToolsComplete = (tools: ChatToolPayloadWithResult[]): bo
return collapsible.every((t) => t.result != null && t.result.content !== LOADING_FLAT);
};
/** Heuristic: prose-only block after last tool looks like a long deliverable (not a one-line step). */
export const scorePostToolBlockAsFinalAnswer = (block: AssistantContentBlock): number => {
if (block.tools && block.tools.length > 0) return 0;
/** Heuristic: visible content already looks like a deliverable, not a one-line status step. */
export const scoreBlockContentAsAnswerLike = (block: AssistantContentBlock): number => {
const raw = (block.content ?? '').trim();
if (!raw || raw === LOADING_FLAT) return 0;
@ -66,6 +65,13 @@ export const scorePostToolBlockAsFinalAnswer = (block: AssistantContentBlock): n
return score;
};
/** Heuristic: prose-only block after last tool looks like a long deliverable (not a one-line step). */
export const scorePostToolBlockAsFinalAnswer = (block: AssistantContentBlock): number => {
if (block.tools && block.tools.length > 0) return 0;
return scoreBlockContentAsAnswerLike(block);
};
/**
* While generating, first index at or after {@param lastToolIndex} whose prose-only block scores
* as final-answer-like. Tail from here stays out of the workflow fold. Returns null if tooling

View file

@ -7,49 +7,59 @@ import { useTranslation } from 'react-i18next';
import { useAgentMeta } from '@/features/Conversation/hooks/useAgentMeta';
import LobeMessage from '@/routes/onboarding/components/LobeMessage';
import FeedbackPanel from './FeedbackPanel';
import { staticStyle } from './staticStyle';
interface CompletionPanelProps {
feedbackSubmitted?: boolean;
finishTargetUrl?: string;
showFeedback?: boolean;
topicId?: string;
}
const CompletionPanel = memo<CompletionPanelProps>(({ finishTargetUrl }) => {
const { t } = useTranslation('onboarding');
const agentMeta = useAgentMeta();
return (
<Center height={'100%'} width={'100%'}>
<Flexbox
className={staticStyle.completionEnter}
gap={14}
style={{ maxWidth: 600, width: '100%' }}
>
<LobeMessage
avatar={agentMeta.avatar}
avatarSize={72}
fontSize={32}
gap={16}
sentences={[
t('agent.completion.sentence.readyWithName', { name: agentMeta.title }),
t('agent.completion.sentence.readyWhenYouAre'),
]}
/>
<Text fontSize={16} type={'secondary'}>
{t('agent.completionSubtitle')}
</Text>
<Button
size={'large'}
style={{ marginTop: 8 }}
type={'primary'}
onClick={() => {
if (finishTargetUrl) window.location.assign(finishTargetUrl);
}}
const CompletionPanel = memo<CompletionPanelProps>(
({ feedbackSubmitted, finishTargetUrl, showFeedback, topicId }) => {
const { t } = useTranslation('onboarding');
const agentMeta = useAgentMeta();
return (
<Center height={'100%'} width={'100%'}>
<Flexbox
align={'center'}
className={staticStyle.completionEnter}
gap={14}
style={{ maxWidth: 600, width: '100%' }}
>
{t('agent.enterApp')}
</Button>
</Flexbox>
</Center>
);
});
<LobeMessage
avatar={agentMeta.avatar}
avatarSize={72}
fontSize={32}
gap={16}
sentences={[
t('agent.completion.sentence.readyWithName', { name: agentMeta.title }),
t('agent.completion.sentence.readyWhenYouAre'),
]}
/>
<Text fontSize={16} type={'secondary'}>
{t('agent.completionSubtitle')}
</Text>
<Button
size={'large'}
style={{ marginTop: 8 }}
type={'primary'}
onClick={() => {
if (finishTargetUrl) window.location.assign(finishTargetUrl);
}}
>
{t('agent.enterApp')}
</Button>
{showFeedback && topicId && (
<FeedbackPanel hasPriorFeedback={!!feedbackSubmitted} topicId={topicId} />
)}
</Flexbox>
</Center>
);
},
);
CompletionPanel.displayName = 'CompletionPanel';

View file

@ -117,6 +117,7 @@ describe('AgentOnboardingConversation', () => {
expect.objectContaining({
allowExpand: false,
leftActions: [],
rightActions: [],
showRuntimeConfig: false,
}),
);

View file

@ -20,15 +20,19 @@ import Welcome from './Welcome';
const assistantLikeRoles = new Set(['assistant', 'assistantGroup', 'supervisor']);
interface AgentOnboardingConversationProps {
feedbackSubmitted?: boolean;
finishTargetUrl?: string;
onboardingFinished?: boolean;
readOnly?: boolean;
showFeedback?: boolean;
topicId?: string;
}
const chatInputLeftActions: ActionKeys[] = isDev ? ['model'] : [];
const chatInputRightActions: ActionKeys[] = [];
const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
({ finishTargetUrl, onboardingFinished, readOnly }) => {
({ feedbackSubmitted, finishTargetUrl, onboardingFinished, readOnly, showFeedback, topicId }) => {
const displayMessages = useConversationStore(conversationSelectors.displayMessages);
const isGreetingState = useMemo(() => {
@ -68,7 +72,15 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
return <Welcome content={message.content} />;
}, [displayMessages, shouldShowGreetingWelcome]);
if (onboardingFinished) return <CompletionPanel finishTargetUrl={finishTargetUrl} />;
if (onboardingFinished)
return (
<CompletionPanel
feedbackSubmitted={feedbackSubmitted}
finishTargetUrl={finishTargetUrl}
showFeedback={showFeedback}
topicId={topicId}
/>
);
const listWelcome = greetingWelcome;
@ -97,6 +109,7 @@ const AgentOnboardingConversation = memo<AgentOnboardingConversationProps>(
<ChatInput
allowExpand={false}
leftActions={chatInputLeftActions}
rightActions={chatInputRightActions}
showRuntimeConfig={false}
/>
)}

View file

@ -0,0 +1,145 @@
'use client';
import { useAnalytics } from '@lobehub/analytics/react';
import { Button, Flexbox, Icon, Text, TextArea } from '@lobehub/ui';
import { ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ONBOARDING_FEEDBACK_CONSTANTS,
type OnboardingFeedbackRating,
submitOnboardingComment,
submitOnboardingRating,
} from '@/services/onboardingFeedback';
interface FeedbackPanelProps {
hasPriorFeedback: boolean;
topicId: string;
}
interface SubmittedRating {
rating: OnboardingFeedbackRating;
submittedAt: string;
}
const FeedbackPanel = memo<FeedbackPanelProps>(({ hasPriorFeedback, topicId }) => {
const { t } = useTranslation('onboarding');
const { analytics } = useAnalytics();
const [done, setDone] = useState(hasPriorFeedback);
const [submittedRating, setSubmittedRating] = useState<SubmittedRating | null>(null);
const [pendingRating, setPendingRating] = useState<OnboardingFeedbackRating | null>(null);
const [comment, setComment] = useState('');
const [sendingComment, setSendingComment] = useState(false);
const [error, setError] = useState<string | null>(null);
if (done) {
return (
<Flexbox align={'center'} paddingBlock={8}>
<Text type={'secondary'}>{t('agent.feedback.thanks')}</Text>
</Flexbox>
);
}
const handleRatingClick = async (next: OnboardingFeedbackRating) => {
if (submittedRating || pendingRating) return;
setPendingRating(next);
setError(null);
try {
const result = await submitOnboardingRating(
{ rating: next, topicId },
{ analytics: analytics ?? null },
);
setSubmittedRating({ rating: next, submittedAt: result.submittedAt });
} catch (submitError) {
console.error('[FeedbackPanel] rating submit failed', submitError);
setError(t('agent.feedback.error'));
} finally {
setPendingRating(null);
}
};
const handleSendComment = async () => {
if (!submittedRating || sendingComment) return;
const trimmed = comment.trim();
if (!trimmed) {
setDone(true);
return;
}
setSendingComment(true);
setError(null);
try {
await submitOnboardingComment({
comment: trimmed,
rating: submittedRating.rating,
submittedAt: submittedRating.submittedAt,
topicId,
});
setDone(true);
} catch (submitError) {
console.error('[FeedbackPanel] comment submit failed', submitError);
setError(t('agent.feedback.error'));
} finally {
setSendingComment(false);
}
};
const activeRating = submittedRating?.rating ?? pendingRating;
const ratingDisabled = !!submittedRating || !!pendingRating;
return (
<Flexbox align={'center'} gap={12} paddingBlock={8} width={'100%'}>
<Text type={'secondary'}>{t('agent.feedback.prompt')}</Text>
<Flexbox horizontal gap={8}>
<Button
aria-label={t('agent.feedback.rateGood')}
disabled={ratingDisabled}
icon={<Icon icon={ThumbsUpIcon} />}
loading={pendingRating === 'good'}
type={activeRating === 'good' ? 'primary' : 'default'}
onClick={() => handleRatingClick('good')}
/>
<Button
aria-label={t('agent.feedback.rateBad')}
disabled={ratingDisabled}
icon={<Icon icon={ThumbsDownIcon} />}
loading={pendingRating === 'bad'}
type={activeRating === 'bad' ? 'primary' : 'default'}
onClick={() => handleRatingClick('bad')}
/>
</Flexbox>
{submittedRating && (
<Flexbox gap={8} style={{ maxWidth: 480, width: '100%' }}>
<TextArea
autoSize={{ maxRows: 6, minRows: 3 }}
disabled={sendingComment}
maxLength={ONBOARDING_FEEDBACK_CONSTANTS.COMMENT_MAX_LENGTH}
placeholder={t('agent.feedback.placeholder')}
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
{error && (
<Text style={{ textAlign: 'center' }} type={'danger'}>
{error}
</Text>
)}
<Flexbox horizontal gap={8} justify={'flex-end'}>
<Button loading={sendingComment} type={'primary'} onClick={handleSendComment}>
{t('agent.feedback.submit')}
</Button>
</Flexbox>
</Flexbox>
)}
{!submittedRating && error && (
<Text style={{ textAlign: 'center' }} type={'danger'}>
{error}
</Text>
)}
</Flexbox>
);
});
FeedbackPanel.displayName = 'FeedbackPanel';
export default FeedbackPanel;

View file

@ -155,9 +155,12 @@ const AgentOnboardingPage = memo(() => {
>
<ErrorBoundary fallbackRender={() => null}>
<AgentOnboardingConversation
feedbackSubmitted={!!data?.feedbackSubmitted}
finishTargetUrl={finishTargetUrl}
onboardingFinished={onboardingFinished}
readOnly={viewingHistoricalTopic}
showFeedback={!viewingHistoricalTopic}
topicId={effectiveTopicId}
/>
</ErrorBoundary>
</OnboardingConversationProvider>

View file

@ -5,7 +5,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import ModeSwitch from './ModeSwitch';
const mockConfig = vi.hoisted(() => ({ agentOnboardingEnabled: true }));
const mockConfig = vi.hoisted(() => ({
agentOnboardingEnabled: true,
desktop: false,
serverConfigInit: true,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@ -20,24 +24,48 @@ vi.mock('react-i18next', () => ({
}),
}));
vi.mock('@/routes/onboarding/config', () => ({
get AGENT_ONBOARDING_ENABLED() {
return mockConfig.agentOnboardingEnabled;
interface RenderModeSwitchOptions {
actions?: ReactNode;
desktop?: boolean;
enabled: boolean;
entry?: string;
serverConfigInit?: boolean;
showLabel?: boolean;
}
vi.mock('@lobechat/const', () => ({
get isDesktop() {
return mockConfig.desktop;
},
}));
vi.mock('@/store/serverConfig', () => ({
useServerConfigStore: <T,>(
selector: (state: {
featureFlags: {
enableAgentOnboarding: boolean;
};
serverConfigInit: boolean;
}) => T,
) => {
return selector({
featureFlags: { enableAgentOnboarding: mockConfig.agentOnboardingEnabled },
serverConfigInit: mockConfig.serverConfigInit,
});
},
}));
const renderModeSwitch = ({
actions,
desktop = false,
enabled,
entry = '/onboarding/agent',
serverConfigInit = true,
showLabel,
}: {
actions?: ReactNode;
enabled: boolean;
entry?: string;
showLabel?: boolean;
}) => {
}: RenderModeSwitchOptions) => {
mockConfig.agentOnboardingEnabled = enabled;
mockConfig.desktop = desktop;
mockConfig.serverConfigInit = serverConfigInit;
render(
<MemoryRouter initialEntries={[entry]}>
@ -49,6 +77,8 @@ const renderModeSwitch = ({
afterEach(() => {
cleanup();
mockConfig.agentOnboardingEnabled = true;
mockConfig.desktop = false;
mockConfig.serverConfigInit = true;
});
describe('ModeSwitch', () => {
@ -68,6 +98,13 @@ describe('ModeSwitch', () => {
expect(screen.queryByText('Choose your onboarding mode')).not.toBeInTheDocument();
});
it('hides the onboarding switch until server config is initialized', () => {
renderModeSwitch({ enabled: true, serverConfigInit: false });
expect(screen.queryByRole('radio', { name: 'Conversational' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'Classic' })).not.toBeInTheDocument();
});
it('keeps action buttons visible when agent onboarding is disabled', () => {
renderModeSwitch({
actions: <button type="button">Restart</button>,
@ -78,4 +115,11 @@ describe('ModeSwitch', () => {
expect(screen.queryByRole('radio', { name: 'Conversational' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'Classic' })).not.toBeInTheDocument();
});
it('does not render the switch on desktop builds', () => {
renderModeSwitch({ desktop: true, enabled: true });
expect(screen.queryByRole('radio', { name: 'Conversational' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'Classic' })).not.toBeInTheDocument();
});
});

View file

@ -1,5 +1,6 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Flexbox, Segmented, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import type { CSSProperties, ReactNode } from 'react';
@ -7,7 +8,7 @@ import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { AGENT_ONBOARDING_ENABLED } from '@/routes/onboarding/config';
import { useServerConfigStore } from '@/store/serverConfig';
const styles = createStaticStyles(({ css, cssVar }) => ({
anchor: css`
@ -53,19 +54,21 @@ const ModeSwitch = memo<ModeSwitchProps>(({ actions, className, showLabel = fals
const { t } = useTranslation('onboarding');
const location = useLocation();
const navigate = useNavigate();
const enableAgentOnboarding = useServerConfigStore((s) => s.featureFlags.enableAgentOnboarding);
const serverConfigInit = useServerConfigStore((s) => s.serverConfigInit);
const mode = useMemo(() => {
return location.pathname.startsWith('/onboarding/agent') ? 'agent' : 'classic';
}, [location.pathname]);
const options = useMemo(() => {
if (!AGENT_ONBOARDING_ENABLED) return [];
if (isDesktop || !serverConfigInit || !enableAgentOnboarding) return [];
return [
{ label: t('agent.modeSwitch.agent'), value: 'agent' as const },
{ label: t('agent.modeSwitch.classic'), value: 'classic' as const },
];
}, [t]);
}, [enableAgentOnboarding, serverConfigInit, t]);
const segmented =
options.length > 0 ? (

View file

@ -460,6 +460,17 @@ export default {
'tool.intervention.mode.autoRunDesc': 'Automatically approve all tool executions',
'tool.intervention.mode.manual': 'Manual',
'tool.intervention.mode.manualDesc': 'Manual approval required for each invocation',
'tool.intervention.onboarding.agentIdentity.applyHint':
'The new identity will appear after approval.',
'tool.intervention.onboarding.agentIdentity.description':
'Approving this change updates the Agent shown in Inbox and in this onboarding conversation.',
'tool.intervention.onboarding.agentIdentity.emoji': 'Agent avatar',
'tool.intervention.onboarding.agentIdentity.eyebrow': 'Onboarding approval',
'tool.intervention.onboarding.agentIdentity.name': 'Agent name',
'tool.intervention.onboarding.agentIdentity.targetInbox': 'Inbox Agent',
'tool.intervention.onboarding.agentIdentity.targetOnboarding': 'Current onboarding Agent',
'tool.intervention.onboarding.agentIdentity.targets': 'Applies to',
'tool.intervention.onboarding.agentIdentity.title': 'Confirm Agent identity update',
'tool.intervention.reject': 'Reject',
'tool.intervention.rejectAndContinue': 'Reject and Retry',
'tool.intervention.rejectOnly': 'Reject',

View file

@ -7,6 +7,10 @@ export default {
'alert.cloud.descOnMobile':
'All registered users get {{credit}} free computing credits per month—no setup needed.',
'alert.cloud.title': '{{name}} beta is live',
'agentOnboardingPromo.actionLabel': 'Try it now',
'agentOnboardingPromo.description':
'Set up your agent teams in a quick chat with Lobe AI. Your existing agents remain unchanged.',
'agentOnboardingPromo.title': 'Quick Wizard',
'appLoading.appIdle': 'Ready to start',
'appLoading.appInitializing': 'Application is starting...',
'appLoading.failed':

View file

@ -20,6 +20,13 @@ export default {
'agent.completion.sentence.readyWhenYouAre': 'Ready when you are :)',
'agent.completion.sentence.readyWithName': "{{name}} here - I'm ready!",
'agent.enterApp': "I'm ready",
'agent.feedback.error': 'Could not save your feedback. Please try again.',
'agent.feedback.placeholder': "Tell us more (optional). What worked, what didn't?",
'agent.feedback.prompt': 'How was this onboarding?',
'agent.feedback.rateBad': 'Rate this onboarding negatively',
'agent.feedback.rateGood': 'Rate this onboarding positively',
'agent.feedback.submit': 'Send feedback',
'agent.feedback.thanks': 'Thanks for the feedback!',
'agent.modeSwitch.reset': 'Reset Flow',
'agent.progress': '{{currentStep}}/{{totalSteps}}',
'agent.stage.agentIdentity': 'Agent Identity',

View file

@ -149,6 +149,7 @@ export default {
'builtins.lobe-agent-documents.apiName.createDocument': 'Create document',
'builtins.lobe-agent-documents.apiName.editDocument': 'Edit document',
'builtins.lobe-agent-documents.apiName.listDocuments': 'List documents',
'builtins.lobe-agent-documents.apiName.patchDocument': 'Patch document',
'builtins.lobe-agent-documents.apiName.readDocument': 'Read document',
'builtins.lobe-agent-documents.apiName.readDocumentByFilename': 'Read document by filename',
'builtins.lobe-agent-documents.apiName.removeDocument': 'Remove document',

View file

@ -0,0 +1,299 @@
import type * as LobechatConst from '@lobechat/const';
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
const analyticsTrack = vi.fn();
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) =>
({
'agentOnboardingPromo.actionLabel': 'Try it now',
'agentOnboardingPromo.description':
'Set up your agent teams in a quick chat with Lobe AI. Your existing agents remain unchanged.',
'agentOnboardingPromo.title': 'Quick Wizard',
'changelog': 'Changelog',
'productHunt.actionLabel': 'Support us',
'productHunt.description': 'Support us on Product Hunt.',
'productHunt.title': "We're on Product Hunt!",
'userPanel.discord': 'Discord',
'userPanel.docs': 'Docs',
'userPanel.feedback': 'Feedback',
'userPanel.help': 'Help',
'userPanel.setting': 'Settings',
})[key] || key,
}),
}));
interface RenderFooterOptions {
agentFinished?: boolean;
agentStarted?: boolean;
classicFinished?: boolean;
desktop?: boolean;
enabled?: boolean;
mobile?: boolean;
readSlugs?: string[];
serverConfigInit?: boolean;
}
let mockGlobalState: Record<string, unknown>;
let mockServerConfigState: Record<string, unknown>;
let mockUserState: Record<string, unknown>;
interface MockStoreHook {
(selector: (state: Record<string, unknown>) => unknown): unknown;
getState: () => Record<string, unknown>;
}
const createGlobalState = (readSlugs: string[] = []) => ({
status: {
readNotificationSlugs: readSlugs,
},
updateSystemStatus: vi.fn((patch: { readNotificationSlugs?: string[] }) => {
mockGlobalState = {
...mockGlobalState,
status: {
...(mockGlobalState.status as Record<string, unknown>),
...patch,
},
};
}),
});
const renderFooter = async ({
agentFinished = false,
agentStarted = false,
classicFinished = true,
desktop = false,
enabled = true,
mobile = false,
readSlugs = [],
serverConfigInit = true,
}: RenderFooterOptions = {}) => {
vi.resetModules();
analyticsTrack.mockReset();
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
removeItem: vi.fn(),
setItem: vi.fn(),
});
mockGlobalState = createGlobalState(readSlugs);
mockServerConfigState = {
featureFlags: { enableAgentOnboarding: enabled },
isMobile: mobile,
serverConfigInit,
};
mockUserState = {
agentOnboarding: {
activeTopicId: agentStarted ? 'topic-1' : undefined,
finishedAt: agentFinished ? '2026-04-15T00:00:00.000Z' : undefined,
},
defaultSettings: {},
onboarding: classicFinished ? { finishedAt: '2026-04-14T00:00:00.000Z' } : undefined,
settings: { general: { isDevMode: false } },
};
vi.doMock('@lobechat/const', async (importOriginal) => {
const actual = (await importOriginal()) as typeof LobechatConst;
return {
...actual,
isDesktop: desktop,
};
});
function createAnalyticsApi() {
return {
analytics: { track: analyticsTrack },
};
}
vi.doMock('@lobehub/analytics/react', () => ({
useAnalytics: createAnalyticsApi,
}));
vi.doMock('@/components/ChangelogModal', () => ({
default: () => null,
}));
vi.doMock('@/components/HighlightNotification', () => ({
default: (props: {
actionLabel?: string;
description?: string;
onAction?: () => void;
onActionClick?: () => void;
onClose?: () => void;
open?: boolean;
title?: string;
}) =>
props.open ? (
<div data-testid="highlight-notification">
<div>{props.title}</div>
<div>{props.description}</div>
<button type="button" onClick={props.onClose}>
Close promo
</button>
{props.actionLabel && (
<button
type="button"
onClick={() => {
if (props.onAction) props.onAction();
else props.onActionClick?.();
}}
>
{props.actionLabel}
</button>
)}
</div>
) : null,
}));
vi.doMock('@/features/User/UserPanel/ThemeButton', () => ({
default: () => null,
}));
function createFeedbackModalApi() {
return { open: vi.fn() };
}
vi.doMock('@/hooks/useFeedbackModal', () => ({
useFeedbackModal: createFeedbackModalApi,
}));
function createNavLayoutState() {
return {
bottomMenuItems: [],
footer: {
hideGitHub: true,
layout: 'compact',
showEvalEntry: false,
showSettingsEntry: true,
},
topNavItems: [],
userPanel: {
showDataImporter: false,
showMemory: true,
},
};
}
vi.doMock('@/hooks/useNavLayout', () => ({
useNavLayout: createNavLayoutState,
}));
const selectFromGlobalStore = ((selector: (state: Record<string, unknown>) => unknown) =>
selector(mockGlobalState)) as MockStoreHook;
vi.doMock('@/store/global', () => {
selectFromGlobalStore.getState = () => mockGlobalState;
return { useGlobalStore: selectFromGlobalStore };
});
function selectFromServerConfigStore(selector: (state: Record<string, unknown>) => unknown) {
return selector(mockServerConfigState);
}
vi.doMock('@/store/serverConfig', () => ({
useServerConfigStore: selectFromServerConfigStore,
}));
function selectFromUserStore(selector: (state: Record<string, unknown>) => unknown) {
return selector(mockUserState);
}
vi.doMock('@/store/user', () => ({
useUserStore: selectFromUserStore,
}));
const { default: Footer } = await import('./index');
render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route element={<Footer />} path="/" />
<Route element={<div>Agent onboarding route</div>} path="/onboarding/agent" />
</Routes>
</MemoryRouter>,
);
};
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.doUnmock('@lobechat/const');
vi.doUnmock('@lobehub/analytics/react');
vi.doUnmock('@/components/ChangelogModal');
vi.doUnmock('@/components/HighlightNotification');
vi.doUnmock('@/features/User/UserPanel/ThemeButton');
vi.doUnmock('@/hooks/useFeedbackModal');
vi.doUnmock('@/hooks/useNavLayout');
vi.doUnmock('@/store/global');
vi.doUnmock('@/store/serverConfig');
vi.doUnmock('@/store/user');
});
describe('Footer agent onboarding promotion', () => {
it('shows the agent onboarding promotion for eligible web users', async () => {
await renderFooter();
expect(screen.getByTestId('highlight-notification')).toBeInTheDocument();
expect(screen.getByText('Quick Wizard')).toBeInTheDocument();
expect(analyticsTrack).toHaveBeenCalledWith({
name: 'agent_onboarding_promo_viewed',
properties: {
spm: 'homepage.agent_onboarding_promo.viewed',
trigger: 'auto',
},
});
}, 40000);
it('stores the dismiss slug when the agent onboarding promotion is closed', async () => {
const user = userEvent.setup();
await renderFooter();
const card = screen.getAllByTestId('highlight-notification').at(-1)!;
await user.click(within(card).getByRole('button', { name: 'Close promo' }));
expect(
(mockGlobalState.status as { readNotificationSlugs: string[] }).readNotificationSlugs,
).toContain('agent-onboarding-promo-v1');
}, 20000);
it('marks the promotion as read and navigates into agent onboarding on CTA click', async () => {
const user = userEvent.setup();
await renderFooter();
const card = screen.getAllByTestId('highlight-notification').at(-1)!;
await user.click(within(card).getByRole('button', { name: 'Try it now' }));
expect(
(mockGlobalState.status as { readNotificationSlugs: string[] }).readNotificationSlugs,
).toContain('agent-onboarding-promo-v1');
expect(screen.getByText('Agent onboarding route')).toBeInTheDocument();
expect(analyticsTrack).toHaveBeenCalledWith({
name: 'agent_onboarding_promo_clicked',
properties: {
spm: 'homepage.agent_onboarding_promo.clicked',
},
});
}, 20000);
it('does not show the promotion after agent onboarding has already started', async () => {
await renderFooter({ agentStarted: true });
expect(screen.queryByTestId('highlight-notification')).not.toBeInTheDocument();
});
it('does not show the promotion when classic onboarding is not finished', async () => {
await renderFooter({ classicFinished: false });
expect(screen.queryByTestId('highlight-notification')).not.toBeInTheDocument();
});
it('does not show the promotion after the current device has dismissed it', async () => {
await renderFooter({ readSlugs: ['agent-onboarding-promo-v1'] });
expect(screen.queryByTestId('highlight-notification')).not.toBeInTheDocument();
});
it('does not show the promotion on mobile web variants', async () => {
await renderFooter({ mobile: true });
expect(screen.queryByTestId('highlight-notification')).not.toBeInTheDocument();
});
it('does not show the promotion on desktop builds', async () => {
await renderFooter({ desktop: true });
expect(screen.queryByTestId('highlight-notification')).not.toBeInTheDocument();
});
});

View file

@ -1,6 +1,7 @@
'use client';
import { SOCIAL_URL } from '@lobechat/business-const';
import { isDesktop } from '@lobechat/const';
import { useAnalytics } from '@lobehub/analytics/react';
import { type MenuProps } from '@lobehub/ui';
import { ActionIcon, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
@ -11,13 +12,15 @@ import {
Feather,
FileClockIcon,
FlaskConical,
MessageCircle,
Rocket,
Settings2,
SettingsIcon,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import ChangelogModal from '@/components/ChangelogModal';
import HighlightNotification from '@/components/HighlightNotification';
@ -27,40 +30,99 @@ import { useFeedbackModal } from '@/hooks/useFeedbackModal';
import { useNavLayout } from '@/hooks/useNavLayout';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors/systemStatus';
import { useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors';
import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors/general';
import { prefetchRoute } from '@/utils/router';
import { resolveFooterPromotionState } from './promotionPipeline';
const AGENT_ONBOARDING_PROMO_SLUG = 'agent-onboarding-promo-v1';
const PRODUCT_HUNT_NOTIFICATION = {
actionHref: 'https://www.producthunt.com/products/lobehub?launch=lobehub',
endTime: new Date('2026-02-01T00:00:00Z'),
image: 'https://hub-apac-1.lobeobjects.space/og/lobehub-ph.png',
slug: 'product-hunt-2026',
startTime: new Date('2026-01-27T08:00:00Z'),
};
} as const;
interface PromotionCard {
actionHref?: string;
actionIcon?: ReactNode;
actionLabel: string;
description: string;
image?: string;
onAction?: () => void;
onActionClick?: () => void;
onClose: () => void;
title: string;
}
const Footer = memo(() => {
const { t } = useTranslation('common');
const navigate = useNavigate();
const { analytics } = useAnalytics();
const { footer } = useNavLayout();
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const location = useLocation();
const isSettingsPage = location.pathname.startsWith('/settings');
const enableAgentOnboarding = useServerConfigStore((s) => s.featureFlags.enableAgentOnboarding);
const isMobile = useServerConfigStore((s) => !!s.isMobile);
const serverConfigInit = useServerConfigStore((s) => s.serverConfigInit);
const [agentOnboardingFinished, agentOnboardingStarted, classicOnboardingFinished, isDevMode] =
useUserStore((s) => [
!!s.agentOnboarding?.finishedAt,
!!s.agentOnboarding?.activeTopicId,
!!s.onboarding?.finishedAt,
userGeneralSettingsSelectors.config(s).isDevMode,
]);
const [shouldLoadChangelog, setShouldLoadChangelog] = useState(false);
const [isAgentOnboardingCardOpen, setIsAgentOnboardingCardOpen] = useState(false);
const [isChangelogModalOpen, setIsChangelogModalOpen] = useState(false);
const [isProductHuntCardOpen, setIsProductHuntCardOpen] = useState(false);
const [isNotificationRead, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.isNotificationRead(PRODUCT_HUNT_NOTIFICATION.slug)(s),
s.updateSystemStatus,
]);
const [isAgentOnboardingPromoRead, isProductHuntNotificationRead, updateSystemStatus] =
useGlobalStore((s) => [
systemStatusSelectors.isNotificationRead(AGENT_ONBOARDING_PROMO_SLUG)(s),
systemStatusSelectors.isNotificationRead(PRODUCT_HUNT_NOTIFICATION.slug)(s),
s.updateSystemStatus,
]);
const isWithinTimeWindow = useMemo(() => {
const now = new Date();
return now >= PRODUCT_HUNT_NOTIFICATION.startTime && now <= PRODUCT_HUNT_NOTIFICATION.endTime;
}, []);
const trackProductHuntEvent = useCallback(
const {
shouldAutoShowAgentOnboardingPromo,
shouldAutoShowProductHuntCard,
shouldShowProductHuntMenuEntry,
} = useMemo(
() =>
resolveFooterPromotionState({
agentOnboardingFinished,
agentOnboardingStarted,
classicOnboardingFinished,
enableAgentOnboarding: !!enableAgentOnboarding,
isAgentOnboardingPromoRead,
isDesktop,
isMobile,
isProductHuntNotificationRead,
isWithinProductHuntWindow: isWithinTimeWindow,
serverConfigInit,
}),
[
agentOnboardingFinished,
agentOnboardingStarted,
classicOnboardingFinished,
enableAgentOnboarding,
isAgentOnboardingPromoRead,
isMobile,
isProductHuntNotificationRead,
isWithinTimeWindow,
serverConfigInit,
],
);
const trackPromotionEvent = useCallback(
(eventName: string, properties: Record<string, string>) => {
try {
analytics?.track({ name: eventName, properties });
@ -71,15 +133,36 @@ const Footer = memo(() => {
[analytics],
);
const markNotificationRead = useCallback(
(slug: string) => {
const currentSlugs = useGlobalStore.getState().status.readNotificationSlugs || [];
if (currentSlugs.includes(slug)) return;
updateSystemStatus({ readNotificationSlugs: [...currentSlugs, slug] });
},
[updateSystemStatus],
);
useEffect(() => {
if (isWithinTimeWindow && !isNotificationRead) {
setIsProductHuntCardOpen(true);
trackProductHuntEvent('product_hunt_card_viewed', {
spm: 'homepage.product_hunt.viewed',
trigger: 'auto',
});
}
}, [isWithinTimeWindow, isNotificationRead, trackProductHuntEvent]);
if (!shouldAutoShowAgentOnboardingPromo) return;
setIsAgentOnboardingCardOpen(true);
trackPromotionEvent('agent_onboarding_promo_viewed', {
spm: 'homepage.agent_onboarding_promo.viewed',
trigger: 'auto',
});
}, [shouldAutoShowAgentOnboardingPromo, trackPromotionEvent]);
useEffect(() => {
if (!shouldAutoShowProductHuntCard) return;
setIsProductHuntCardOpen(true);
trackPromotionEvent('product_hunt_card_viewed', {
spm: 'homepage.product_hunt.viewed',
trigger: 'auto',
});
}, [isWithinTimeWindow, shouldAutoShowProductHuntCard, trackPromotionEvent]);
const { open: openFeedbackModal } = useFeedbackModal();
@ -96,32 +179,79 @@ const Footer = memo(() => {
openFeedbackModal();
}, [openFeedbackModal]);
const handleCloseAgentOnboardingCard = useCallback(() => {
setIsAgentOnboardingCardOpen(false);
markNotificationRead(AGENT_ONBOARDING_PROMO_SLUG);
trackPromotionEvent('agent_onboarding_promo_closed', {
spm: 'homepage.agent_onboarding_promo.closed',
});
}, [markNotificationRead, trackPromotionEvent]);
const handleAgentOnboardingAction = useCallback(() => {
setIsAgentOnboardingCardOpen(false);
markNotificationRead(AGENT_ONBOARDING_PROMO_SLUG);
trackPromotionEvent('agent_onboarding_promo_clicked', {
spm: 'homepage.agent_onboarding_promo.clicked',
});
navigate('/onboarding/agent');
}, [markNotificationRead, navigate, trackPromotionEvent]);
const handleOpenProductHuntCard = useCallback(() => {
setIsProductHuntCardOpen(true);
trackProductHuntEvent('product_hunt_card_viewed', {
trackPromotionEvent('product_hunt_card_viewed', {
spm: 'homepage.product_hunt.viewed',
trigger: 'menu_click',
});
}, [setIsProductHuntCardOpen, trackProductHuntEvent]);
}, [trackPromotionEvent]);
const handleCloseProductHuntCard = () => {
const handleCloseProductHuntCard = useCallback(() => {
setIsProductHuntCardOpen(false);
if (!isNotificationRead) {
const currentSlugs = useGlobalStore.getState().status.readNotificationSlugs || [];
updateSystemStatus({
readNotificationSlugs: [...currentSlugs, PRODUCT_HUNT_NOTIFICATION.slug],
});
}
trackProductHuntEvent('product_hunt_card_closed', {
markNotificationRead(PRODUCT_HUNT_NOTIFICATION.slug);
trackPromotionEvent('product_hunt_card_closed', {
spm: 'homepage.product_hunt.closed',
});
};
}, [markNotificationRead, trackPromotionEvent]);
const handleProductHuntActionClick = () => {
trackProductHuntEvent('product_hunt_action_clicked', {
const handleProductHuntActionClick = useCallback(() => {
trackPromotionEvent('product_hunt_action_clicked', {
spm: 'homepage.product_hunt.action_clicked',
});
};
}, [trackPromotionEvent]);
const activePromotion = useMemo<PromotionCard | undefined>(() => {
if (isAgentOnboardingCardOpen) {
return {
actionIcon: <Icon icon={MessageCircle} size={14} />,
actionLabel: t('agentOnboardingPromo.actionLabel'),
description: t('agentOnboardingPromo.description'),
onAction: handleAgentOnboardingAction,
onClose: handleCloseAgentOnboardingCard,
title: t('agentOnboardingPromo.title'),
};
}
if (isProductHuntCardOpen) {
return {
actionHref: PRODUCT_HUNT_NOTIFICATION.actionHref,
actionLabel: t('productHunt.actionLabel'),
description: t('productHunt.description'),
image: PRODUCT_HUNT_NOTIFICATION.image,
onActionClick: handleProductHuntActionClick,
onClose: handleCloseProductHuntCard,
title: t('productHunt.title'),
};
}
return undefined;
}, [
handleAgentOnboardingAction,
handleCloseAgentOnboardingCard,
handleCloseProductHuntCard,
handleProductHuntActionClick,
isAgentOnboardingCardOpen,
isProductHuntCardOpen,
t,
]);
const helpMenuItems: MenuProps['items'] = useMemo(
() => [
@ -192,7 +322,7 @@ const Footer = memo(() => {
},
]
: []),
...(isWithinTimeWindow
...(shouldShowProductHuntMenuEntry
? [
{
icon: <Icon icon={Rocket} />,
@ -208,11 +338,11 @@ const Footer = memo(() => {
footer.layout,
footer.hideGitHub,
footer.showEvalEntry,
isDevMode,
t,
handleOpenFeedbackModal,
isWithinTimeWindow,
handleOpenProductHuntCard,
isDevMode,
shouldShowProductHuntMenuEntry,
t,
],
);
@ -257,16 +387,20 @@ const Footer = memo(() => {
shouldLoad={shouldLoadChangelog}
onClose={handleCloseChangelogModal}
/>
<HighlightNotification
actionHref={PRODUCT_HUNT_NOTIFICATION.actionHref}
actionLabel={t('productHunt.actionLabel')}
description={t('productHunt.description')}
image={PRODUCT_HUNT_NOTIFICATION.image}
open={isProductHuntCardOpen}
title={t('productHunt.title')}
onActionClick={handleProductHuntActionClick}
onClose={handleCloseProductHuntCard}
/>
{activePromotion && (
<HighlightNotification
open
actionHref={activePromotion.actionHref}
actionIcon={activePromotion.actionIcon}
actionLabel={activePromotion.actionLabel}
description={activePromotion.description}
image={activePromotion.image}
title={activePromotion.title}
onAction={activePromotion.onAction}
onActionClick={activePromotion.onActionClick}
onClose={activePromotion.onClose}
/>
)}
</>
);
});

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import type { FooterPromotionContext } from './promotionPipeline';
import { resolveFooterPromotionState } from './promotionPipeline';
const createContext = (overrides: Partial<FooterPromotionContext> = {}) => ({
agentOnboardingFinished: false,
agentOnboardingStarted: false,
classicOnboardingFinished: true,
enableAgentOnboarding: true,
isAgentOnboardingPromoRead: false,
isDesktop: false,
isMobile: false,
isProductHuntNotificationRead: false,
isWithinProductHuntWindow: true,
serverConfigInit: true,
...overrides,
});
describe('resolveFooterPromotionState', () => {
it('prioritizes the agent onboarding promotion over product hunt', () => {
expect(resolveFooterPromotionState(createContext())).toEqual({
isAgentOnboardingPromoAvailable: true,
shouldAutoShowAgentOnboardingPromo: true,
shouldAutoShowProductHuntCard: false,
shouldShowProductHuntMenuEntry: false,
});
});
it('falls back to product hunt when agent onboarding promotion is unavailable', () => {
expect(
resolveFooterPromotionState(createContext({ classicOnboardingFinished: false })),
).toEqual({
isAgentOnboardingPromoAvailable: false,
shouldAutoShowAgentOnboardingPromo: false,
shouldAutoShowProductHuntCard: true,
shouldShowProductHuntMenuEntry: true,
});
});
it('keeps the product hunt menu entry while suppressing auto-open after read', () => {
expect(
resolveFooterPromotionState(
createContext({
classicOnboardingFinished: false,
isProductHuntNotificationRead: true,
}),
),
).toEqual({
isAgentOnboardingPromoAvailable: false,
shouldAutoShowAgentOnboardingPromo: false,
shouldAutoShowProductHuntCard: false,
shouldShowProductHuntMenuEntry: true,
});
});
it('returns an empty state when no promotion is eligible', () => {
expect(
resolveFooterPromotionState(
createContext({
classicOnboardingFinished: false,
isWithinProductHuntWindow: false,
}),
),
).toEqual({
isAgentOnboardingPromoAvailable: false,
shouldAutoShowAgentOnboardingPromo: false,
shouldAutoShowProductHuntCard: false,
shouldShowProductHuntMenuEntry: false,
});
});
});

View file

@ -0,0 +1,76 @@
interface FooterPromotionContext {
agentOnboardingFinished: boolean;
agentOnboardingStarted: boolean;
classicOnboardingFinished: boolean;
enableAgentOnboarding: boolean;
isAgentOnboardingPromoRead: boolean;
isDesktop: boolean;
isMobile: boolean;
isProductHuntNotificationRead: boolean;
isWithinProductHuntWindow: boolean;
serverConfigInit: boolean;
}
interface FooterPromotionState {
isAgentOnboardingPromoAvailable: boolean;
shouldAutoShowAgentOnboardingPromo: boolean;
shouldAutoShowProductHuntCard: boolean;
shouldShowProductHuntMenuEntry: boolean;
}
type FooterPromotionPipelineStep = (
context: FooterPromotionContext,
state: FooterPromotionState,
) => FooterPromotionState;
const initialFooterPromotionState: FooterPromotionState = {
isAgentOnboardingPromoAvailable: false,
shouldAutoShowAgentOnboardingPromo: false,
shouldAutoShowProductHuntCard: false,
shouldShowProductHuntMenuEntry: false,
};
const resolveAgentOnboardingPromotion: FooterPromotionPipelineStep = (context, state) => {
const isAgentOnboardingPromoAvailable =
!context.isDesktop &&
!context.isMobile &&
context.serverConfigInit &&
context.enableAgentOnboarding &&
context.classicOnboardingFinished &&
!context.agentOnboardingStarted &&
!context.agentOnboardingFinished;
if (!isAgentOnboardingPromoAvailable) return state;
return {
...state,
isAgentOnboardingPromoAvailable,
shouldAutoShowAgentOnboardingPromo: !context.isAgentOnboardingPromoRead,
};
};
const resolveProductHuntPromotion: FooterPromotionPipelineStep = (context, state) => {
if (state.isAgentOnboardingPromoAvailable || !context.isWithinProductHuntWindow) return state;
return {
...state,
shouldAutoShowProductHuntCard:
context.serverConfigInit && !context.isProductHuntNotificationRead,
shouldShowProductHuntMenuEntry: true,
};
};
const footerPromotionPipeline = [
resolveAgentOnboardingPromotion,
resolveProductHuntPromotion,
] as const satisfies readonly FooterPromotionPipelineStep[];
export const resolveFooterPromotionState = (
context: FooterPromotionContext,
): FooterPromotionState =>
footerPromotionPipeline.reduce(
(state, step) => step(context, state),
initialFooterPromotionState,
);
export type { FooterPromotionContext, FooterPromotionState };

View file

@ -2,13 +2,36 @@ import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
const renderAgentRoute = async (enabled: boolean) => {
interface RenderAgentRouteOptions {
desktop?: boolean;
enabled: boolean;
serverConfigInit?: boolean;
}
const renderAgentRoute = async ({
desktop = false,
enabled,
serverConfigInit = true,
}: RenderAgentRouteOptions) => {
vi.resetModules();
vi.doMock('@lobechat/const', () => ({
isDesktop: desktop,
}));
vi.doMock('@/components/Loading/BrandTextLoading', () => ({
default: ({ debugId }: { debugId: string }) => <div>{debugId}</div>,
}));
vi.doMock('@/features/Onboarding/Agent', () => ({
default: () => <div>Agent onboarding</div>,
}));
vi.doMock('@/routes/onboarding/config', () => ({
AGENT_ONBOARDING_ENABLED: enabled,
function selectFromServerConfigStore(selector: (state: Record<string, unknown>) => unknown) {
return selector({
featureFlags: { enableAgentOnboarding: enabled },
serverConfigInit,
});
}
vi.doMock('@/store/serverConfig', () => ({
useServerConfigStore: selectFromServerConfigStore,
}));
const { default: AgentOnboardingRoute } = await import('./index');
@ -24,19 +47,33 @@ const renderAgentRoute = async (enabled: boolean) => {
};
afterEach(() => {
vi.doUnmock('@lobechat/const');
vi.doUnmock('@/components/Loading/BrandTextLoading');
vi.doUnmock('@/features/Onboarding/Agent');
vi.doUnmock('@/routes/onboarding/config');
vi.doUnmock('@/store/serverConfig');
});
describe('AgentOnboardingRoute', () => {
it('renders the agent onboarding page when the feature is enabled', async () => {
await renderAgentRoute(true);
await renderAgentRoute({ enabled: true });
expect(screen.getByText('Agent onboarding')).toBeInTheDocument();
});
it('shows a loading state before the server config is initialized', async () => {
await renderAgentRoute({ enabled: true, serverConfigInit: false });
expect(screen.getByText('AgentOnboardingRoute')).toBeInTheDocument();
});
it('redirects to classic onboarding when the feature is disabled', async () => {
await renderAgentRoute(false);
await renderAgentRoute({ enabled: false });
expect(screen.getByText('Classic onboarding')).toBeInTheDocument();
});
it('redirects to classic onboarding on desktop builds', async () => {
await renderAgentRoute({ desktop: true, enabled: true });
expect(screen.getByText('Classic onboarding')).toBeInTheDocument();
});

View file

@ -1,11 +1,22 @@
import { isDesktop } from '@lobechat/const';
import { memo } from 'react';
import { Navigate } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading';
import AgentOnboardingPage from '@/features/Onboarding/Agent';
import { AGENT_ONBOARDING_ENABLED } from '@/routes/onboarding/config';
import { useServerConfigStore } from '@/store/serverConfig';
const AgentOnboardingRoute = memo(() => {
if (!AGENT_ONBOARDING_ENABLED) {
const enableAgentOnboarding = useServerConfigStore((s) => s.featureFlags.enableAgentOnboarding);
const serverConfigInit = useServerConfigStore((s) => s.serverConfigInit);
if (isDesktop) {
return <Navigate replace to="/onboarding/classic" />;
}
if (!serverConfigInit) return <Loading debugId="AgentOnboardingRoute" />;
if (!enableAgentOnboarding) {
return <Navigate replace to="/onboarding/classic" />;
}

View file

@ -23,11 +23,13 @@ const LobeMessage = memo<LobeMessageProps>(
return (
<Flexbox gap={gap} {...rest}>
{avatar ? (
<Avatar avatar={avatar} size={avatarSize || fontSize * 2} />
) : (
<ProductLogo size={avatarSize || fontSize * 2} />
)}
<Flexbox align={'center'} justify={'center'}>
{avatar ? (
<Avatar avatar={avatar} size={avatarSize || fontSize * 2} />
) : (
<ProductLogo size={avatarSize || fontSize * 2} />
)}
</Flexbox>
<Text as={'h1'} fontSize={fontSize} weight={'bold'}>
<TypewriterEffect
cursorCharacter={<LoadingDots size={fontSize} variant={'pulse'} />}

View file

@ -1,4 +1,3 @@
import { AGENT_ONBOARDING_ENABLED } from '@lobechat/business-const';
import {
ChartNetworkIcon,
CodeXmlIcon,
@ -12,11 +11,10 @@ import {
/** Default target when the user opens `/onboarding`. Flip to `'agent'` when agent onboarding is ready to ship as the primary flow. */
export type DefaultOnboardingEntryVariant = 'agent' | 'classic';
export { AGENT_ONBOARDING_ENABLED };
export const DEFAULT_ONBOARDING_ENTRY_VARIANT: DefaultOnboardingEntryVariant = 'classic';
const resolveDefaultOnboardingPath = (variant: DefaultOnboardingEntryVariant) =>
variant === 'agent' && AGENT_ONBOARDING_ENABLED ? '/onboarding/agent' : '/onboarding/classic';
variant === 'agent' ? '/onboarding/agent' : '/onboarding/classic';
export const DEFAULT_ONBOARDING_PATH: '/onboarding/agent' | '/onboarding/classic' =
resolveDefaultOnboardingPath(DEFAULT_ONBOARDING_ENTRY_VARIANT);

View file

@ -559,6 +559,13 @@ export const topicRouter = router({
boundDeviceId: z.string().optional(),
ccSessionId: z.string().optional(),
model: z.string().optional(),
onboardingFeedback: z
.object({
comment: z.string().max(500).optional(),
rating: z.enum(['good', 'bad']),
submittedAt: z.string(),
})
.optional(),
provider: z.string().optional(),
runningOperation: z
.object({

View file

@ -1,5 +1,6 @@
import { EMPTY_DOCUMENT_MESSAGES } from '@lobechat/builtin-tool-web-onboarding/utils';
import { isDesktop } from '@lobechat/const';
import { applyMarkdownPatch, formatMarkdownPatchError } from '@lobechat/markdown-patch';
import {
type UserInitializationState,
type UserPreference,
@ -276,6 +277,71 @@ export const userRouter = router({
return { id: result.document.id, type: 'persona' as const };
}),
patchOnboardingDocument: userProcedure
.input(
z.object({
hunks: z
.array(
z.object({
replace: z.string(),
replaceAll: z.boolean().optional(),
search: z.string(),
}),
)
.min(1),
type: z.enum(['soul', 'persona']),
}),
)
.mutation(async ({ ctx, input }) => {
const readCurrent = async (): Promise<string> => {
if (input.type === 'soul') {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
const docService = new AgentDocumentsService(ctx.serverDB, ctx.userId);
const inboxAgentId = await onboardingService.getInboxAgentId();
const doc = await docService.getDocumentByFilename(inboxAgentId, 'SOUL.md');
return doc?.content ?? '';
}
const { UserPersonaModel } = await import('@/database/models/userMemory/persona');
const personaModel = new UserPersonaModel(ctx.serverDB, ctx.userId);
const persona = await personaModel.getLatestPersonaDocument();
return persona?.persona ?? '';
};
const current = await readCurrent();
const patched = applyMarkdownPatch(current, input.hunks);
if (!patched.ok) {
throw new TRPCError({
cause: patched.error,
code: 'BAD_REQUEST',
message: formatMarkdownPatchError(patched.error),
});
}
if (input.type === 'soul') {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);
const docService = new AgentDocumentsService(ctx.serverDB, ctx.userId);
const inboxAgentId = await onboardingService.getInboxAgentId();
const doc = await docService.upsertDocumentByFilename({
agentId: inboxAgentId,
content: patched.content,
filename: 'SOUL.md',
});
return { applied: patched.applied, id: doc?.id, type: 'soul' as const };
}
const { UserPersonaModel } = await import('@/database/models/userMemory/persona');
const personaModel = new UserPersonaModel(ctx.serverDB, ctx.userId);
const result = await personaModel.upsertPersona({
editedBy: 'agent_tool',
persona: patched.content,
profile: 'default',
});
return { applied: patched.applied, id: result.document.id, type: 'persona' as const };
}),
resetAgentOnboarding: userProcedure.mutation(async ({ ctx }) => {
const onboardingService = new OnboardingService(ctx.serverDB, ctx.userId);

View file

@ -244,9 +244,29 @@ describe('OnboardingService', () => {
expect(result.topicId).toBe('topic-1');
expect(result.agentOnboarding.activeTopicId).toBe('topic-1');
expect(result.feedbackSubmitted).toBe(false);
expect(mockMessageModel.create).toHaveBeenCalledTimes(1);
});
it('reports feedbackSubmitted when topic.metadata.onboardingFeedback is present', async () => {
persistedUserState.agentOnboarding = {
activeTopicId: 'topic-1',
version: CURRENT_ONBOARDING_VERSION,
};
mockTopicModel.findById.mockResolvedValue({
agentId: 'web-onboarding-agent',
id: 'topic-1',
metadata: {
onboardingFeedback: { rating: 'good', submittedAt: '2026-04-16T00:00:00.000Z' },
},
});
const service = new OnboardingService(mockDb, userId);
const result = await service.getOrCreateState();
expect(result.feedbackSubmitted).toBe(true);
});
it('transfers the onboarding topic to the inbox agent when finishing', async () => {
persistedUserState.agentOnboarding = {
activeTopicId: 'topic-1',

View file

@ -325,10 +325,13 @@ export class OnboardingService {
await this.ensureWelcomeMessage(topicId, builtinAgent.id);
const topic = await this.topicModel.findById(topicId);
return {
agentId: builtinAgent.id,
agentOnboarding: nextState,
context: await this.getState(),
feedbackSubmitted: !!topic?.metadata?.onboardingFeedback,
topicId,
};
};

View file

@ -0,0 +1,153 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { topicService } from '@/services/topic';
import {
ONBOARDING_FEEDBACK_CONSTANTS,
submitOnboardingComment,
submitOnboardingRating,
} from './index';
describe('submitOnboardingRating', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('writes the rating to topic.metadata.onboardingFeedback and returns the timestamp', async () => {
const updateTopicMetadata = vi
.spyOn(topicService, 'updateTopicMetadata')
.mockResolvedValue(undefined as never);
const result = await submitOnboardingRating({ rating: 'good', topicId: 'topic-1' });
expect(updateTopicMetadata).toHaveBeenCalledTimes(1);
const [topicId, metadata] = updateTopicMetadata.mock.calls[0]!;
expect(topicId).toBe('topic-1');
expect(metadata.onboardingFeedback?.rating).toBe('good');
expect(metadata.onboardingFeedback?.comment).toBeUndefined();
expect(typeof metadata.onboardingFeedback?.submittedAt).toBe('string');
expect(result.submittedAt).toBe(metadata.onboardingFeedback?.submittedAt);
});
it('emits the analytics event without any free-form comment', async () => {
vi.spyOn(topicService, 'updateTopicMetadata').mockResolvedValue(undefined as never);
const track = vi.fn();
await submitOnboardingRating({ rating: 'good', topicId: 'topic-2' }, { analytics: { track } });
expect(track).toHaveBeenCalledTimes(1);
const event = track.mock.calls[0]![0];
expect(event.name).toBe(ONBOARDING_FEEDBACK_CONSTANTS.EVENT_NAME);
expect(event.properties).toEqual({
rating: 'good',
spm: ONBOARDING_FEEDBACK_CONSTANTS.SPM,
});
expect(Object.keys(event.properties)).not.toContain('comment');
});
it('rejects when topic metadata persistence fails', async () => {
vi.spyOn(topicService, 'updateTopicMetadata').mockRejectedValue(
new Error('topic write failed'),
);
await expect(submitOnboardingRating({ rating: 'good', topicId: 'topic-3' })).rejects.toThrow(
'topic write failed',
);
});
it('does not throw when analytics emission fails', async () => {
vi.spyOn(topicService, 'updateTopicMetadata').mockResolvedValue(undefined as never);
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await expect(
submitOnboardingRating(
{ rating: 'good', topicId: 'topic-4' },
{
analytics: {
track: () => {
throw new Error('analytics down');
},
},
},
),
).resolves.toMatchObject({ submittedAt: expect.any(String) });
expect(consoleError).toHaveBeenCalled();
});
});
describe('submitOnboardingComment', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('patches topic metadata with the trimmed comment carrying rating + submittedAt forward', async () => {
const updateTopicMetadata = vi
.spyOn(topicService, 'updateTopicMetadata')
.mockResolvedValue(undefined as never);
await submitOnboardingComment({
comment: ' great onboarding ',
rating: 'good',
submittedAt: '2026-04-16T00:00:00.000Z',
topicId: 'topic-5',
});
expect(updateTopicMetadata).toHaveBeenCalledTimes(1);
const [topicId, metadata] = updateTopicMetadata.mock.calls[0]!;
expect(topicId).toBe('topic-5');
expect(metadata.onboardingFeedback).toEqual({
comment: 'great onboarding',
rating: 'good',
submittedAt: '2026-04-16T00:00:00.000Z',
});
});
it('truncates comments longer than the configured cap', async () => {
const updateTopicMetadata = vi
.spyOn(topicService, 'updateTopicMetadata')
.mockResolvedValue(undefined as never);
const oversize = 'x'.repeat(ONBOARDING_FEEDBACK_CONSTANTS.COMMENT_MAX_LENGTH + 50);
await submitOnboardingComment({
comment: oversize,
rating: 'bad',
submittedAt: '2026-04-16T00:00:00.000Z',
topicId: 'topic-6',
});
const metadata = updateTopicMetadata.mock.calls[0]![1];
expect(metadata.onboardingFeedback?.comment?.length).toBe(
ONBOARDING_FEEDBACK_CONSTANTS.COMMENT_MAX_LENGTH,
);
});
it('no-ops when the trimmed comment is empty', async () => {
const updateTopicMetadata = vi
.spyOn(topicService, 'updateTopicMetadata')
.mockResolvedValue(undefined as never);
await submitOnboardingComment({
comment: ' ',
rating: 'good',
submittedAt: '2026-04-16T00:00:00.000Z',
topicId: 'topic-7',
});
expect(updateTopicMetadata).not.toHaveBeenCalled();
});
it('rejects when topic metadata persistence fails so the UI can surface an error', async () => {
vi.spyOn(topicService, 'updateTopicMetadata').mockRejectedValue(
new Error('topic write failed'),
);
await expect(
submitOnboardingComment({
comment: 'feedback',
rating: 'good',
submittedAt: '2026-04-16T00:00:00.000Z',
topicId: 'topic-8',
}),
).rejects.toThrow('topic write failed');
});
});

View file

@ -0,0 +1,100 @@
import { topicService } from '@/services/topic';
const COMMENT_MAX_LENGTH = 500;
const FEEDBACK_EVENT_NAME = 'onboarding_feedback_submitted';
const FEEDBACK_SPM = 'onboarding.completion.feedback.submit';
export type OnboardingFeedbackRating = 'good' | 'bad';
export interface OnboardingRatingPayload {
rating: OnboardingFeedbackRating;
topicId: string;
}
export interface OnboardingCommentPayload {
comment: string;
rating: OnboardingFeedbackRating;
submittedAt: string;
topicId: string;
}
export interface OnboardingRatingResult {
submittedAt: string;
}
interface AnalyticsLike {
track: (event: { name: string; properties?: Record<string, unknown> }) => unknown;
}
export interface SubmitOnboardingRatingOptions {
/** Optional analytics client (fire-and-forget). Pass null to skip analytics. */
analytics?: AnalyticsLike | null;
}
const sanitizeComment = (comment: string | undefined): string | undefined => {
if (!comment) return undefined;
const trimmed = comment.trim();
if (!trimmed) return undefined;
return trimmed.slice(0, COMMENT_MAX_LENGTH);
};
/**
* Fires immediately when the user clicks a thumb. Persists the rating to
* topic.metadata.onboardingFeedback (gating write) and emits the analytics event
* best-effort. Returns `submittedAt` so a later comment patch can carry the same
* timestamp + rating through `topicService.updateTopicMetadata`, which
* shallow-merges top-level keys.
*/
export const submitOnboardingRating = async (
payload: OnboardingRatingPayload,
options: SubmitOnboardingRatingOptions = {},
): Promise<OnboardingRatingResult> => {
const submittedAt = new Date().toISOString();
await topicService.updateTopicMetadata(payload.topicId, {
onboardingFeedback: {
rating: payload.rating,
submittedAt,
},
});
try {
options.analytics?.track({
name: FEEDBACK_EVENT_NAME,
properties: {
rating: payload.rating,
spm: FEEDBACK_SPM,
},
});
} catch (error) {
console.error('[OnboardingFeedback] analytics emit failed', error);
}
return { submittedAt };
};
/**
* Patches `topic.metadata.onboardingFeedback` with the user's free-form comment
* after the rating has been recorded. Carries `rating` + `submittedAt` forward
* because `updateTopicMetadata` shallow-merges and would otherwise overwrite
* those fields. No-ops on empty trimmed comment so callers can call unconditionally.
* The comment is never emitted to analytics kept on topic.metadata only.
*/
export const submitOnboardingComment = async (payload: OnboardingCommentPayload): Promise<void> => {
const comment = sanitizeComment(payload.comment);
if (!comment) return;
await topicService.updateTopicMetadata(payload.topicId, {
onboardingFeedback: {
comment,
rating: payload.rating,
submittedAt: payload.submittedAt,
},
});
};
export const ONBOARDING_FEEDBACK_CONSTANTS = {
COMMENT_MAX_LENGTH,
EVENT_NAME: FEEDBACK_EVENT_NAME,
SPM: FEEDBACK_SPM,
};

View file

@ -83,6 +83,11 @@ export class TopicService {
metadata: {
boundDeviceId?: string;
model?: string;
onboardingFeedback?: {
comment?: string;
rating: 'good' | 'bad';
submittedAt: string;
};
provider?: string;
runningOperation?: { assistantMessageId: string; operationId: string } | null;
workingDirectory?: string;

View file

@ -1,3 +1,4 @@
import { type MarkdownPatchHunk } from '@lobechat/markdown-patch';
import { type PartialDeep } from 'type-fest';
import { lambdaClient } from '@/libs/trpc/client';
@ -34,6 +35,7 @@ export class UserService {
agentId: string;
agentOnboarding: UserAgentOnboarding;
context: UserAgentOnboardingContext;
feedbackSubmitted: boolean;
topicId: string;
}> => {
return lambdaClient.user.getOrCreateOnboardingState.query();
@ -61,6 +63,10 @@ export class UserService {
return lambdaClient.user.updateOnboardingDocument.mutate({ content, type });
};
patchOnboardingDocument = async (type: 'soul' | 'persona', hunks: MarkdownPatchHunk[]) => {
return lambdaClient.user.patchOnboardingDocument.mutate({ hunks, type });
};
makeUserOnboarded = async () => {
return lambdaClient.user.makeUserOnboarded.mutate();
};

View file

@ -6,11 +6,12 @@ import { createServerConfigStore } from './store';
// Mock SWR
let mockSWRData: GlobalRuntimeConfig | undefined;
let mockSWRError: Error | undefined;
let mockOnSuccessCallback: ((data: GlobalRuntimeConfig) => void) | undefined;
vi.mock('@/libs/swr', () => ({
useOnlyFetchOnceSWR: vi.fn((key, fetcher, options) => {
const { onSuccess } = options || {};
const { onError, onSuccess } = options || {};
mockOnSuccessCallback = onSuccess;
// Simulate SWR behavior
@ -18,9 +19,13 @@ vi.mock('@/libs/swr', () => ({
onSuccess(mockSWRData);
}
if (mockSWRError && onError) {
onError(mockSWRError);
}
return {
data: mockSWRData,
error: undefined,
error: mockSWRError,
isLoading: false,
isValidating: false,
mutate: vi.fn(),
@ -45,11 +50,13 @@ beforeEach(() => {
vi.resetModules();
mockSWRData = mockGlobalConfig;
mockSWRError = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
mockSWRData = undefined;
mockSWRError = undefined;
mockOnSuccessCallback = undefined;
});
@ -76,6 +83,18 @@ describe('ServerConfigAction', () => {
expect(state.featureFlags).toBeDefined();
});
it('should mark server config as initialized when the fetch fails', () => {
mockSWRData = undefined;
mockSWRError = new Error('network error');
const store = createServerConfigStore();
store.getState().useInitServerConfig();
expect(store.getState().serverConfigInit).toBe(true);
expect(store.getState().serverConfig).toEqual({ aiProvider: {}, telemetry: {} });
});
it('should pass a fetcher function that calls globalService', async () => {
const { useOnlyFetchOnceSWR } = vi.mocked(await import('@/libs/swr'));
@ -140,6 +159,7 @@ describe('ServerConfigAction', () => {
'FETCH_SERVER_CONFIG',
expect.any(Function),
expect.objectContaining({
onError: expect.any(Function),
onSuccess: expect.any(Function),
}),
);

View file

@ -30,6 +30,9 @@ export class ServerConfigActionImpl {
FETCH_SERVER_CONFIG_KEY,
() => globalService.getGlobalConfig(),
{
onError: () => {
this.#set({ serverConfigInit: true }, false, 'initServerConfigFallback');
},
onSuccess: (data) => {
this.#set(
{

View file

@ -1,3 +1,4 @@
import { InterventionChecker } from '@lobechat/agent-runtime';
import { WebOnboardingApiName, WebOnboardingManifest } from '@lobechat/builtin-tool-web-onboarding';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@ -51,6 +52,17 @@ describe('webOnboardingExecutor', () => {
expect(saveUserQuestionApi).toMatchObject({
description: expect.stringContaining('agentName and agentEmoji'),
humanIntervention: [
{
match: { agentName: { pattern: '\\S', type: 'regex' } },
policy: 'always',
},
{
match: { agentEmoji: { pattern: '\\S', type: 'regex' } },
policy: 'always',
},
{ policy: 'never' },
],
parameters: {
additionalProperties: false,
properties: {
@ -68,6 +80,44 @@ describe('webOnboardingExecutor', () => {
});
});
it('requires approval only when saveUserQuestion updates agent identity fields', () => {
const saveUserQuestionApi = WebOnboardingManifest.api.find(
(api) => api.name === WebOnboardingApiName.saveUserQuestion,
);
const humanIntervention = saveUserQuestionApi?.humanIntervention;
expect(humanIntervention).toBeDefined();
expect(Array.isArray(humanIntervention)).toBe(true);
if (!Array.isArray(humanIntervention)) {
throw new TypeError('saveUserQuestion humanIntervention must use static rules');
}
expect(
InterventionChecker.shouldIntervene({
config: humanIntervention,
securityBlacklist: [],
toolArgs: { agentName: 'Atlas' },
}),
).toBe('always');
expect(
InterventionChecker.shouldIntervene({
config: humanIntervention,
securityBlacklist: [],
toolArgs: { agentEmoji: '🛰️' },
}),
).toBe('always');
expect(
InterventionChecker.shouldIntervene({
config: humanIntervention,
securityBlacklist: [],
toolArgs: { fullName: 'Ada Lovelace' },
}),
).toBe('never');
});
it('calls finishOnboarding service and syncs user state', async () => {
finishOnboardingSpy.mockResolvedValue({
agentId: 'inbox-agent-1',

View file

@ -1,5 +1,6 @@
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import {
type PatchDocumentArgs,
WebOnboardingApiName,
WebOnboardingIdentifier,
} from '@lobechat/builtin-tool-web-onboarding';
@ -80,6 +81,33 @@ class WebOnboardingExecutor extends BaseExecutor<typeof WebOnboardingApiName> {
success: true,
};
};
patchDocument = async (
params: PatchDocumentArgs,
_ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
const result = await userService.patchOnboardingDocument(params.type, params.hunks);
if (!result.id) {
return { content: `Failed to patch ${params.type} document.`, success: false };
}
return {
content: `Patched ${params.type} document (${result.id}). Applied ${result.applied} hunk(s).`,
state: { applied: result.applied, id: result.id, type: params.type },
success: true,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: message,
error: { message, type: 'MarkdownPatchError' },
state: { type: params.type },
success: false,
};
}
};
}
export const webOnboardingExecutor = new WebOnboardingExecutor();