diff --git a/locales/ar/common.json b/locales/ar/common.json index 68910a08e9..24035a5640 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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}} من أرصدة الحوسبة المجانية شهريًا — دون الحاجة إلى إعداد.", diff --git a/locales/bg-BG/common.json b/locales/bg-BG/common.json index 4c3121112a..b2b5d60ddc 100644 --- a/locales/bg-BG/common.json +++ b/locales/bg-BG/common.json @@ -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}} безплатни изчислителни кредити на месец — без нужда от настройка.", diff --git a/locales/de-DE/common.json b/locales/de-DE/common.json index d2a89d1a09..94586f28d9 100644 --- a/locales/de-DE/common.json +++ b/locales/de-DE/common.json @@ -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.", diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index 9a0e6f6a08..0d1bc6731f 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -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", diff --git a/locales/en-US/common.json b/locales/en-US/common.json index b9e99068a9..38bcf113a7 100644 --- a/locales/en-US/common.json +++ b/locales/en-US/common.json @@ -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.", diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json index 85478b6ee2..e59e4ddb80 100644 --- a/locales/en-US/plugin.json +++ b/locales/en-US/plugin.json @@ -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", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index ba389cd39c..c947be8df1 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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.", diff --git a/locales/fa-IR/common.json b/locales/fa-IR/common.json index b9f2799060..e593dd3d1c 100644 --- a/locales/fa-IR/common.json +++ b/locales/fa-IR/common.json @@ -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}} اعتبار رایگان محاسباتی دریافت می‌کنند — بدون نیاز به تنظیمات.", diff --git a/locales/fr-FR/common.json b/locales/fr-FR/common.json index ecdc2912d5..18830b2d24 100644 --- a/locales/fr-FR/common.json +++ b/locales/fr-FR/common.json @@ -1,6 +1,9 @@ { "about": "À propos", "advanceSettings": "Paramètres avancés", + "agentOnboardingPromo.actionLabel": "Essayer maintenant", + "agentOnboardingPromo.description": "Configurez vos équipes d’agents 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.", diff --git a/locales/it-IT/common.json b/locales/it-IT/common.json index 6e760f38a5..87b3f49478 100644 --- a/locales/it-IT/common.json +++ b/locales/it-IT/common.json @@ -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.", diff --git a/locales/ja-JP/common.json b/locales/ja-JP/common.json index 9437b42135..c01c02cfad 100644 --- a/locales/ja-JP/common.json +++ b/locales/ja-JP/common.json @@ -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}}の無料コンピューティングクレジットを受け取れます—設定は不要です。", diff --git a/locales/ko-KR/common.json b/locales/ko-KR/common.json index b406d616c0..bb45acf433 100644 --- a/locales/ko-KR/common.json +++ b/locales/ko-KR/common.json @@ -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}}의 무료 컴퓨팅 크레딧을 받을 수 있습니다—설정이 필요하지 않습니다.", diff --git a/locales/nl-NL/common.json b/locales/nl-NL/common.json index a99781b824..121c115d51 100644 --- a/locales/nl-NL/common.json +++ b/locales/nl-NL/common.json @@ -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.", diff --git a/locales/pl-PL/common.json b/locales/pl-PL/common.json index 47e69b2147..1ecc75df1c 100644 --- a/locales/pl-PL/common.json +++ b/locales/pl-PL/common.json @@ -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.", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 40298ae781..a26303a4df 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -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.", diff --git a/locales/ru-RU/common.json b/locales/ru-RU/common.json index 40f7a7807b..c0fd4461bc 100644 --- a/locales/ru-RU/common.json +++ b/locales/ru-RU/common.json @@ -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}} бесплатных вычислительных кредитов в месяц — без необходимости настройки.", diff --git a/locales/tr-TR/common.json b/locales/tr-TR/common.json index eb80ff9c46..72f719616c 100644 --- a/locales/tr-TR/common.json +++ b/locales/tr-TR/common.json @@ -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.", diff --git a/locales/vi-VN/common.json b/locales/vi-VN/common.json index d5476adad9..2635997656 100644 --- a/locales/vi-VN/common.json +++ b/locales/vi-VN/common.json @@ -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.", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index ddfb72e1e6..f4e2cb40d1 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -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": "拒绝后继续", diff --git a/locales/zh-CN/common.json b/locales/zh-CN/common.json index 2bff932c0b..465ed782ba 100644 --- a/locales/zh-CN/common.json +++ b/locales/zh-CN/common.json @@ -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}} 免费计算额度—无需设置。", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index 61e178d426..b66fc7535c 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -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": "删除文档", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 5e63b5ad7e..45402c8675 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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}} 免費運算點數—無需設定。", diff --git a/package.json b/package.json index e4a6065505..3c6f101624 100644 --- a/package.json +++ b/package.json @@ -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:*", diff --git a/packages/builtin-agents/src/agents/web-onboarding/systemRole.ts b/packages/builtin-agents/src/agents/web-onboarding/systemRole.ts index a95edca75a..a4df0a9e11 100644 --- a/packages/builtin-agents/src/agents/web-onboarding/systemRole.ts +++ b/packages/builtin-agents/src/agents/web-onboarding/systemRole.ts @@ -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. diff --git a/packages/builtin-tool-agent-documents/package.json b/packages/builtin-tool-agent-documents/package.json index d1886b9008..5e863b439e 100644 --- a/packages/builtin-tool-agent-documents/package.json +++ b/packages/builtin-tool-agent-documents/package.json @@ -9,6 +9,9 @@ "./executionRuntime": "./src/ExecutionRuntime/index.ts" }, "main": "./src/index.ts", + "dependencies": { + "@lobechat/markdown-patch": "workspace:*" + }, "devDependencies": { "@lobechat/types": "workspace:*" } diff --git a/packages/builtin-tool-agent-documents/src/ExecutionRuntime/index.ts b/packages/builtin-tool-agent-documents/src/ExecutionRuntime/index.ts index afc40b2371..98d804161c 100644 --- a/packages/builtin-tool-agent-documents/src/ExecutionRuntime/index.ts +++ b/packages/builtin-tool-agent-documents/src/ExecutionRuntime/index.ts @@ -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 { + 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, diff --git a/packages/builtin-tool-agent-documents/src/client/Inspector/AgentDocumentsInspector/index.tsx b/packages/builtin-tool-agent-documents/src/client/Inspector/AgentDocumentsInspector/index.tsx index f1f46686ec..549122fa7c 100644 --- a/packages/builtin-tool-agent-documents/src/client/Inspector/AgentDocumentsInspector/index.tsx +++ b/packages/builtin-tool-agent-documents/src/client/Inspector/AgentDocumentsInspector/index.tsx @@ -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'); } diff --git a/packages/builtin-tool-agent-documents/src/client/Inspector/index.ts b/packages/builtin-tool-agent-documents/src/client/Inspector/index.ts index 6ae27c6aef..7a5808004e 100644 --- a/packages/builtin-tool-agent-documents/src/client/Inspector/index.ts +++ b/packages/builtin-tool-agent-documents/src/client/Inspector/index.ts @@ -7,6 +7,7 @@ export const AgentDocumentsInspectors: Record = { [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, diff --git a/packages/builtin-tool-agent-documents/src/executor/index.ts b/packages/builtin-tool-agent-documents/src/executor/index.ts index e64706ba9f..fecb7144aa 100644 --- a/packages/builtin-tool-agent-documents/src/executor/index.ts +++ b/packages/builtin-tool-agent-documents/src/executor/index.ts @@ -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 => { + return this.runtime.patchDocument(params, { agentId: ctx.agentId }); + }; + removeDocument = async ( params: RemoveDocumentArgs, ctx: BuiltinToolContext, diff --git a/packages/builtin-tool-agent-documents/src/index.ts b/packages/builtin-tool-agent-documents/src/index.ts index 2374a3ac17..f98587d0aa 100644 --- a/packages/builtin-tool-agent-documents/src/index.ts +++ b/packages/builtin-tool-agent-documents/src/index.ts @@ -12,6 +12,8 @@ export { type EditDocumentState, type ListDocumentsArgs, type ListDocumentsState, + type PatchDocumentArgs, + type PatchDocumentState, type ReadDocumentArgs, type ReadDocumentByFilenameArgs, type ReadDocumentByFilenameState, diff --git a/packages/builtin-tool-agent-documents/src/manifest.ts b/packages/builtin-tool-agent-documents/src/manifest.ts index bea6c2591b..68536d5fc2 100644 --- a/packages/builtin-tool-agent-documents/src/manifest.ts +++ b/packages/builtin-tool-agent-documents/src/manifest.ts @@ -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, diff --git a/packages/builtin-tool-agent-documents/src/systemRole.ts b/packages/builtin-tool-agent-documents/src/systemRole.ts index cbfa22aedf..e25d3e751f 100644 --- a/packages/builtin-tool-agent-documents/src/systemRole.ts +++ b/packages/builtin-tool-agent-documents/src/systemRole.ts @@ -3,11 +3,12 @@ export const systemPrompt = `You have access to an Agent Documents tool for crea 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 @@ -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. diff --git a/packages/builtin-tool-agent-documents/src/types.ts b/packages/builtin-tool-agent-documents/src/types.ts index 64310b5561..633f4569b5 100644 --- a/packages/builtin-tool-agent-documents/src/types.ts +++ b/packages/builtin-tool-agent-documents/src/types.ts @@ -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; } diff --git a/packages/builtin-tool-web-onboarding/package.json b/packages/builtin-tool-web-onboarding/package.json index 03854ef62a..3ad205825d 100644 --- a/packages/builtin-tool-web-onboarding/package.json +++ b/packages/builtin-tool-web-onboarding/package.json @@ -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": "*" } } diff --git a/packages/builtin-tool-web-onboarding/src/ExecutionRuntime/index.ts b/packages/builtin-tool-web-onboarding/src/ExecutionRuntime/index.ts index 3e13a28e35..388738259b 100644 --- a/packages/builtin-tool-web-onboarding/src/ExecutionRuntime/index.ts +++ b/packages/builtin-tool-web-onboarding/src/ExecutionRuntime/index.ts @@ -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 { + 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, + }; + } } diff --git a/packages/builtin-tool-web-onboarding/src/client/Intervention/SaveUserQuestion.tsx b/packages/builtin-tool-web-onboarding/src/client/Intervention/SaveUserQuestion.tsx new file mode 100644 index 0000000000..e5b68140fc --- /dev/null +++ b/packages/builtin-tool-web-onboarding/src/client/Intervention/SaveUserQuestion.tsx @@ -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>( + ({ 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 ( + + + + {t('tool.intervention.onboarding.agentIdentity.eyebrow')} + + + {t('tool.intervention.onboarding.agentIdentity.title')} + + + {t('tool.intervention.onboarding.agentIdentity.description')} + + + +
+ + + + + + {agentName || t('untitledAgent')} + + + {t('tool.intervention.onboarding.agentIdentity.applyHint')} + + + + +
+ {changedFields.map((field) => ( + + + {field.label} + +
{field.value}
+
+ ))} +
+ + + + {t('tool.intervention.onboarding.agentIdentity.targets')} + +
+ + {t('tool.intervention.onboarding.agentIdentity.targetInbox')} + + + {t('tool.intervention.onboarding.agentIdentity.targetOnboarding')} + +
+
+
+
+
+ ); + }, +); + +SaveUserQuestionIntervention.displayName = 'SaveUserQuestionIntervention'; + +export default SaveUserQuestionIntervention; diff --git a/packages/builtin-tool-web-onboarding/src/client/Intervention/index.ts b/packages/builtin-tool-web-onboarding/src/client/Intervention/index.ts new file mode 100644 index 0000000000..89cfcb27a1 --- /dev/null +++ b/packages/builtin-tool-web-onboarding/src/client/Intervention/index.ts @@ -0,0 +1,10 @@ +import type { BuiltinIntervention } from '@lobechat/types'; + +import { WebOnboardingApiName } from '../../types'; +import SaveUserQuestionIntervention from './SaveUserQuestion'; + +export const WebOnboardingInterventions: Record = { + [WebOnboardingApiName.saveUserQuestion]: SaveUserQuestionIntervention as BuiltinIntervention, +}; + +export { default as SaveUserQuestionIntervention } from './SaveUserQuestion'; diff --git a/packages/builtin-tool-web-onboarding/src/client/index.ts b/packages/builtin-tool-web-onboarding/src/client/index.ts new file mode 100644 index 0000000000..4457dd65c5 --- /dev/null +++ b/packages/builtin-tool-web-onboarding/src/client/index.ts @@ -0,0 +1,3 @@ +export { WebOnboardingManifest } from '../manifest'; +export * from '../types'; +export { WebOnboardingInterventions } from './Intervention'; diff --git a/packages/builtin-tool-web-onboarding/src/index.ts b/packages/builtin-tool-web-onboarding/src/index.ts index 8ed1f49ef9..1462c7801d 100644 --- a/packages/builtin-tool-web-onboarding/src/index.ts +++ b/packages/builtin-tool-web-onboarding/src/index.ts @@ -1,2 +1,7 @@ export { WebOnboardingManifest } from './manifest'; -export { WebOnboardingApiName, WebOnboardingIdentifier } from './types'; +export { + type PatchDocumentArgs, + WebOnboardingApiName, + type WebOnboardingDocumentType, + WebOnboardingIdentifier, +} from './types'; diff --git a/packages/builtin-tool-web-onboarding/src/manifest.ts b/packages/builtin-tool-web-onboarding/src/manifest.ts index 2d2f464464..34e2e9d14a 100644 --- a/packages/builtin-tool-web-onboarding/src/manifest.ts +++ b/packages/builtin-tool-web-onboarding/src/manifest.ts @@ -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: { diff --git a/packages/builtin-tool-web-onboarding/src/toolSystemRole.ts b/packages/builtin-tool-web-onboarding/src/toolSystemRole.ts index 28fafdf52d..ea82acd010 100644 --- a/packages/builtin-tool-web-onboarding/src/toolSystemRole.ts +++ b/packages/builtin-tool-web-onboarding/src/toolSystemRole.ts @@ -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 and 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 and 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. diff --git a/packages/builtin-tool-web-onboarding/src/types.ts b/packages/builtin-tool-web-onboarding/src/types.ts index dd32385e11..7ca9fae693 100644 --- a/packages/builtin-tool-web-onboarding/src/types.ts +++ b/packages/builtin-tool-web-onboarding/src/types.ts @@ -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; +} diff --git a/packages/builtin-tools/src/interventions.ts b/packages/builtin-tools/src/interventions.ts index 94fe60ddc3..9a0a27e3bd 100644 --- a/packages/builtin-tools/src/interventions.ts +++ b/packages/builtin-tools/src/interventions.ts @@ -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> = { [MessageManifest.identifier]: MessageInterventions, [NotebookManifest.identifier]: NotebookInterventions, [UserInteractionIdentifier]: UserInteractionInterventions, + [WebOnboardingManifest.identifier]: WebOnboardingInterventions, }; /** diff --git a/packages/context-engine/src/providers/OnboardingActionHintInjector.ts b/packages/context-engine/src/providers/OnboardingActionHintInjector.ts index def0ec4dab..0251d72625 100644 --- a/packages/context-engine/src/providers/OnboardingActionHintInjector.ts +++ b/packages/context-engine/src/providers/OnboardingActionHintInjector.ts @@ -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.', diff --git a/packages/markdown-patch/package.json b/packages/markdown-patch/package.json new file mode 100644 index 0000000000..dd8026c31a --- /dev/null +++ b/packages/markdown-patch/package.json @@ -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" + } +} diff --git a/packages/markdown-patch/src/applyMarkdownPatch.test.ts b/packages/markdown-patch/src/applyMarkdownPatch.test.ts new file mode 100644 index 0000000000..d099aa3e59 --- /dev/null +++ b/packages/markdown-patch/src/applyMarkdownPatch.test.ts @@ -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/); + }); +}); diff --git a/packages/markdown-patch/src/applyMarkdownPatch.ts b/packages/markdown-patch/src/applyMarkdownPatch.ts new file mode 100644 index 0000000000..290faac82f --- /dev/null +++ b/packages/markdown-patch/src/applyMarkdownPatch.ts @@ -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 }; +}; diff --git a/packages/markdown-patch/src/formatPatchError.ts b/packages/markdown-patch/src/formatPatchError.ts new file mode 100644 index 0000000000..f094ed7803 --- /dev/null +++ b/packages/markdown-patch/src/formatPatchError.ts @@ -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.`; + } + } +}; diff --git a/packages/markdown-patch/src/index.ts b/packages/markdown-patch/src/index.ts new file mode 100644 index 0000000000..15111716b8 --- /dev/null +++ b/packages/markdown-patch/src/index.ts @@ -0,0 +1,10 @@ +export { applyMarkdownPatch } from './applyMarkdownPatch'; +export { formatMarkdownPatchError } from './formatPatchError'; +export type { + MarkdownPatchErrorCode, + MarkdownPatchErrorDetail, + MarkdownPatchFailure, + MarkdownPatchHunk, + MarkdownPatchResult, + MarkdownPatchSuccess, +} from './types'; diff --git a/packages/markdown-patch/src/types.ts b/packages/markdown-patch/src/types.ts new file mode 100644 index 0000000000..8e74bc6933 --- /dev/null +++ b/packages/markdown-patch/src/types.ts @@ -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; diff --git a/packages/markdown-patch/vitest.config.mts b/packages/markdown-patch/vitest.config.mts new file mode 100644 index 0000000000..4ac6027d57 --- /dev/null +++ b/packages/markdown-patch/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); diff --git a/packages/types/src/topic/topic.ts b/packages/types/src/topic/topic.ts index 54bc5d70eb..a81334c714 100644 --- a/packages/types/src/topic/topic.ts +++ b/packages/types/src/topic/topic.ts @@ -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. diff --git a/src/components/FeedbackModal/index.tsx b/src/components/FeedbackModal/index.tsx index f41a788ba5..29c6338fac 100644 --- a/src/components/FeedbackModal/index.tsx +++ b/src/components/FeedbackModal/index.tsx @@ -75,7 +75,7 @@ const FeedbackModal = memo(({ 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, diff --git a/src/components/HighlightNotification/index.tsx b/src/components/HighlightNotification/index.tsx index c601e96f09..963a97eec9 100644 --- a/src/components/HighlightNotification/index.tsx +++ b/src/components/HighlightNotification/index.tsx @@ -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( - ({ open, onClose, onActionClick, image, title, description, actionLabel, actionHref }) => { + ({ + actionHref, + actionIcon = , + actionLabel, + actionTarget = '_blank', + description, + image, + onAction, + onActionClick, + onClose, + open, + title, + }) => { if (!open) return null; + const actionContent = actionLabel ? ( + + {actionIcon && {actionIcon}} + {actionLabel} + + ) : null; + return ( @@ -74,19 +104,30 @@ const HighlightNotification = memo( {title &&
{title}
} {description &&
{description}
} - {actionLabel && ( + {actionLabel && actionHref && ( - )} + {actionLabel && !actionHref && ( + + )}
diff --git a/src/config/featureFlags/schema.test.ts b/src/config/featureFlags/schema.test.ts index c6b002f0af..72c70dd8e1 100644 --- a/src/config/featureFlags/schema.test.ts +++ b/src/config/featureFlags/schema.test.ts @@ -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); diff --git a/src/config/featureFlags/schema.ts b/src/config/featureFlags/schema.ts index 6dfea8f5ce..188f09444d 100644 --- a/src/config/featureFlags/schema.ts +++ b/src/config/featureFlags/schema.ts @@ -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), diff --git a/src/features/Conversation/Messages/AssistantGroup/Tool/Detail/Intervention/webOnboarding.test.tsx b/src/features/Conversation/Messages/AssistantGroup/Tool/Detail/Intervention/webOnboarding.test.tsx new file mode 100644 index 0000000000..ad50c30b95 --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/Tool/Detail/Intervention/webOnboarding.test.tsx @@ -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 }) =>
{avatar}
, + Flexbox: ({ children }: { children?: ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + Text: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( + {children} + ), +})); + +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 + )[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(); + + 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(); + }); +}); diff --git a/src/features/Conversation/Messages/AssistantGroup/components/ContentBlock.tsx b/src/features/Conversation/Messages/AssistantGroup/components/ContentBlock.tsx index 87eb541070..a46b94ee9a 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/ContentBlock.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/ContentBlock.tsx @@ -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( - ({ 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( 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( } return ( - + {showReasoning && ( diff --git a/src/features/Conversation/Messages/AssistantGroup/components/ContentBlocksScroll.tsx b/src/features/Conversation/Messages/AssistantGroup/components/ContentBlocksScroll.tsx index b73b74eb96..f0ee374289 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/ContentBlocksScroll.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/ContentBlocksScroll.tsx @@ -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((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((props) => { {blocks.map((block) => ( ({ + Flexbox: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +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 }) =>
{content}
, +})); + +vi.mock('./WorkflowCollapse', () => ({ + default: ({ + blocks, + }: { + blocks: Array<{ content: string; domId?: string; tools?: unknown[] }>; + }) => ( +
({ + 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[]; + }) => ( +
+ ), +})); + +const blk = (p: Partial & { 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( + , + ); + + 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( + , + ); + + expect(screen.queryByTestId('answer-segment')).not.toBeInTheDocument(); + expect(parseWorkflowSegment()).toEqual([ + { + content: '现在我来搜索资料。', + domId: undefined, + toolCount: 1, + }, + ]); + }); +}); diff --git a/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx b/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx index a8eedcf1de..af7cf3c230 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx @@ -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 => { - const answerBlocks: AssistantContentBlock[] = []; - const workingBlocks: AssistantContentBlock[] = []; +const createAnswerRenderBlock = ( + block: AssistantContentBlock, + overrides: Partial = {}, +): 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 => { + 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( ]); 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( return ( - {workingBlocks.length > 0 && ( - - )} - {answerBlocks.map((item) => { + {segments.map((segment, index) => { + if (segment.kind === 'workflow') { + if (segment.blocks.length === 0) return null; + + return ( + + ); + } + + const item = segment.block; if (!isGenerating && isEmptyBlock(item)) return null; return ( @@ -206,7 +326,7 @@ const Group = memo( assistantId={id} contentId={contentId} disableEditing={disableEditing} - key={id + '.' + item.id} + key={item.renderKey ?? `${id}.${item.id}.${index}`} messageIndex={messageIndex} /> ); diff --git a/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx b/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx index 6ac25934b0..baaac2e1f9 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/GroupItem.tsx @@ -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; diff --git a/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx index 3fe2512337..8a44c9d1a8 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx @@ -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 ?? []); }; diff --git a/src/features/Conversation/Messages/AssistantGroup/components/WorkflowExpandedList.tsx b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowExpandedList.tsx index 9d8dd69087..755022ed10 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/WorkflowExpandedList.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowExpandedList.tsx @@ -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; diff --git a/src/features/Conversation/Messages/AssistantGroup/components/types.ts b/src/features/Conversation/Messages/AssistantGroup/components/types.ts new file mode 100644 index 0000000000..6c2326c5bb --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/components/types.ts @@ -0,0 +1,6 @@ +import type { AssistantContentBlock } from '@/types/index'; + +export interface RenderableAssistantContentBlock extends AssistantContentBlock { + domId?: string; + renderKey?: string; +} diff --git a/src/features/Conversation/Messages/AssistantGroup/constants.ts b/src/features/Conversation/Messages/AssistantGroup/constants.ts index 7322e88d7e..a6e8788695 100644 --- a/src/features/Conversation/Messages/AssistantGroup/constants.ts +++ b/src/features/Conversation/Messages/AssistantGroup/constants.ts @@ -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). */ diff --git a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts index 1d63cc57aa..590c774016 100644 --- a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts +++ b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts @@ -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'; diff --git a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.ts b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.ts index 1ec5ed2abe..b8be9d6fd8 100644 --- a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.ts +++ b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.ts @@ -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 diff --git a/src/features/Onboarding/Agent/CompletionPanel.tsx b/src/features/Onboarding/Agent/CompletionPanel.tsx index 767ef3dacd..df774499d3 100644 --- a/src/features/Onboarding/Agent/CompletionPanel.tsx +++ b/src/features/Onboarding/Agent/CompletionPanel.tsx @@ -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(({ finishTargetUrl }) => { - const { t } = useTranslation('onboarding'); - const agentMeta = useAgentMeta(); - return ( -
- - - - {t('agent.completionSubtitle')} - - - -
- ); -}); + + + {t('agent.completionSubtitle')} + + + {showFeedback && topicId && ( + + )} +
+ + ); + }, +); CompletionPanel.displayName = 'CompletionPanel'; diff --git a/src/features/Onboarding/Agent/Conversation.test.tsx b/src/features/Onboarding/Agent/Conversation.test.tsx index 1729f8bf2e..4437aab2ea 100644 --- a/src/features/Onboarding/Agent/Conversation.test.tsx +++ b/src/features/Onboarding/Agent/Conversation.test.tsx @@ -117,6 +117,7 @@ describe('AgentOnboardingConversation', () => { expect.objectContaining({ allowExpand: false, leftActions: [], + rightActions: [], showRuntimeConfig: false, }), ); diff --git a/src/features/Onboarding/Agent/Conversation.tsx b/src/features/Onboarding/Agent/Conversation.tsx index 30ddb029cc..15eea5cfbf 100644 --- a/src/features/Onboarding/Agent/Conversation.tsx +++ b/src/features/Onboarding/Agent/Conversation.tsx @@ -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( - ({ finishTargetUrl, onboardingFinished, readOnly }) => { + ({ feedbackSubmitted, finishTargetUrl, onboardingFinished, readOnly, showFeedback, topicId }) => { const displayMessages = useConversationStore(conversationSelectors.displayMessages); const isGreetingState = useMemo(() => { @@ -68,7 +72,15 @@ const AgentOnboardingConversation = memo( return ; }, [displayMessages, shouldShowGreetingWelcome]); - if (onboardingFinished) return ; + if (onboardingFinished) + return ( + + ); const listWelcome = greetingWelcome; @@ -97,6 +109,7 @@ const AgentOnboardingConversation = memo( )} diff --git a/src/features/Onboarding/Agent/FeedbackPanel.tsx b/src/features/Onboarding/Agent/FeedbackPanel.tsx new file mode 100644 index 0000000000..693e41db59 --- /dev/null +++ b/src/features/Onboarding/Agent/FeedbackPanel.tsx @@ -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(({ hasPriorFeedback, topicId }) => { + const { t } = useTranslation('onboarding'); + const { analytics } = useAnalytics(); + + const [done, setDone] = useState(hasPriorFeedback); + const [submittedRating, setSubmittedRating] = useState(null); + const [pendingRating, setPendingRating] = useState(null); + const [comment, setComment] = useState(''); + const [sendingComment, setSendingComment] = useState(false); + const [error, setError] = useState(null); + + if (done) { + return ( + + {t('agent.feedback.thanks')} + + ); + } + + 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 ( + + {t('agent.feedback.prompt')} + +