From 2d837c9cb416a45b2885c46a86b4a0f54da9fa7a Mon Sep 17 00:00:00 2001 From: Junyan Chin Date: Thu, 26 Feb 2026 13:50:14 +0800 Subject: [PATCH] feat: add in-product survey system (#2008) * feat: add in-product survey system - SurveyManager: event-based trigger, Space API communication - Trigger on first successful non-WebSocket response - Backend API: /api/v1/survey/{pending,respond,dismiss} - Frontend: floating survey widget with progressive questions - Flat radio/checkbox style (not dropdown Select) * fix: persist triggered survey events to disk across restarts Store triggered events in data/survey_triggered_events.json so that restarting the process doesn't re-query Space for already-triggered events. * fix: use metadata table for survey event persistence instead of file Store triggered events in the existing metadata KV table (key='survey_triggered_events') instead of a standalone JSON file. * fix: ruff format and prettier fixes --- .../pkg/api/http/controller/groups/survey.py | 47 +++ src/langbot/pkg/core/app.py | 3 + src/langbot/pkg/core/stages/build_app.py | 6 + .../pkg/pipeline/process/handlers/chat.py | 5 + src/langbot/pkg/survey/__init__.py | 1 + src/langbot/pkg/survey/manager.py | 148 +++++++ .../home/components/survey/SurveyWidget.tsx | 391 ++++++++++++++++++ web/src/app/home/layout.tsx | 3 + web/src/app/infra/http/BackendClient.ts | 46 +++ 9 files changed, 650 insertions(+) create mode 100644 src/langbot/pkg/api/http/controller/groups/survey.py create mode 100644 src/langbot/pkg/survey/__init__.py create mode 100644 src/langbot/pkg/survey/manager.py create mode 100644 web/src/app/home/components/survey/SurveyWidget.tsx diff --git a/src/langbot/pkg/api/http/controller/groups/survey.py b/src/langbot/pkg/api/http/controller/groups/survey.py new file mode 100644 index 00000000..dcfd7f9e --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/survey.py @@ -0,0 +1,47 @@ +import quart + +from .. import group + + +@group.group_class('survey', '/api/v1/survey') +class SurveyRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/pending', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _get_pending() -> str: + """Get pending survey for the frontend to display.""" + survey = self.ap.survey.get_pending_survey() if self.ap.survey else None + return self.success(data={'survey': survey}) + + @self.route('/respond', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _respond() -> str: + """Submit survey response.""" + json_data = await quart.request.json + survey_id = json_data.get('survey_id') + answers = json_data.get('answers', {}) + completed = json_data.get('completed', True) + + if not survey_id: + return self.fail(1, 'survey_id required') + + if self.ap.survey: + ok = await self.ap.survey.submit_response(survey_id, answers, completed) + if ok: + return self.success() + return self.fail(2, 'Failed to submit response') + return self.fail(3, 'Survey not available') + + @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _dismiss() -> str: + """Dismiss survey.""" + json_data = await quart.request.json + survey_id = json_data.get('survey_id') + + if not survey_id: + return self.fail(1, 'survey_id required') + + if self.ap.survey: + ok = await self.ap.survey.dismiss_survey(survey_id) + if ok: + return self.success() + return self.fail(2, 'Failed to dismiss') + return self.fail(3, 'Survey not available') diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 4d81b2db..a5b096f3 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -39,6 +39,7 @@ from . import entities as core_entities from ..rag.knowledge import kbmgr as rag_mgr from ..vector import mgr as vectordb_mgr from ..telemetry import telemetry as telemetry_module +from ..survey import manager as survey_module class Application: @@ -147,6 +148,8 @@ class Application: telemetry: telemetry_module.TelemetryManager = None + survey: survey_module.SurveyManager = None + monitoring_service: monitoring_service.MonitoringService = None def __init__(self): diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index e294436f..f0398a25 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -34,6 +34,7 @@ from ...utils import logcache from ...vector import mgr as vectordb_mgr from .. import taskmgr from ...telemetry import telemetry as telemetry_module +from ...survey import manager as survey_module @stage.stage_class('BuildAppStage') @@ -110,6 +111,11 @@ class BuildAppStage(stage.BootingStage): await telemetry_inst.initialize() ap.telemetry = telemetry_inst + # Survey manager + survey_inst = survey_module.SurveyManager(ap) + await survey_inst.initialize() + ap.survey = survey_inst + cmd_mgr_inst = cmdmgr.CommandManager(ap) await cmd_mgr_inst.initialize() ap.cmd_mgr = cmd_mgr_inst diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index dec7bcce..242fc78c 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -200,6 +200,11 @@ class ChatMessageHandler(handler.MessageHandler): # Send telemetry asynchronously and do not block pipeline via app's telemetry manager await self.ap.telemetry.start_send_task(payload) + + # Trigger survey event on first successful non-WebSocket response + if not locals().get('error_info') and adapter_name and 'WebSocket' not in adapter_name: + if self.ap.survey: + await self.ap.survey.trigger_event('first_bot_response_success') except Exception as ex: # Ensure telemetry issues do not affect normal flow self.ap.logger.warning(f'Failed to send telemetry: {ex}') diff --git a/src/langbot/pkg/survey/__init__.py b/src/langbot/pkg/survey/__init__.py new file mode 100644 index 00000000..165a7802 --- /dev/null +++ b/src/langbot/pkg/survey/__init__.py @@ -0,0 +1 @@ +"""Survey module for in-product surveys triggered by events.""" diff --git a/src/langbot/pkg/survey/manager.py b/src/langbot/pkg/survey/manager.py new file mode 100644 index 00000000..0e554050 --- /dev/null +++ b/src/langbot/pkg/survey/manager.py @@ -0,0 +1,148 @@ +"""Survey manager: tracks events, communicates with Space to fetch/submit surveys.""" + +from __future__ import annotations + +import asyncio +import json +import typing +import httpx +import sqlalchemy + +from ..core import app as core_app +from ..entity.persistence.metadata import Metadata +from ..utils import constants + +SURVEY_TRIGGERED_KEY = 'survey_triggered_events' + + +class SurveyManager: + """Manages survey lifecycle: event tracking, pending survey fetch, submission.""" + + def __init__(self, ap: core_app.Application): + self.ap = ap + self._triggered_events: set[str] = set() + self._pending_survey: typing.Optional[dict] = None + self._space_url: str = '' + + async def initialize(self): + space_config = self.ap.instance_config.data.get('space', {}) + self._space_url = space_config.get('url', '').rstrip('/') + await self._load_triggered_events() + + async def _load_triggered_events(self): + """Load previously triggered events from metadata table.""" + try: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY) + ) + row = result.first() + if row: + self._triggered_events = set(json.loads(row[0].value)) + except Exception: + self._triggered_events = set() + + async def _save_triggered_events(self): + """Persist triggered events to metadata table.""" + try: + value = json.dumps(list(self._triggered_events)) + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY) + ) + if result.first(): + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY).values(value=value) + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(Metadata).values(key=SURVEY_TRIGGERED_KEY, value=value) + ) + except Exception as e: + self.ap.logger.debug(f'Failed to save survey triggered events: {e}') + + def _is_space_configured(self) -> bool: + space_config = self.ap.instance_config.data.get('space', {}) + if space_config.get('disable_telemetry', False): + return False + return bool(self._space_url) + + async def trigger_event(self, event: str): + """Called when an event occurs. Checks Space for a pending survey.""" + if event in self._triggered_events: + return + if not self._is_space_configured(): + return + + self._triggered_events.add(event) + await self._save_triggered_events() + + # Check for pending survey asynchronously + asyncio.create_task(self._fetch_pending_survey(event)) + + async def _fetch_pending_survey(self, event: str): + """Fetch pending survey from Space for this event.""" + try: + url = f'{self._space_url}/api/v1/survey/pending' + payload = { + 'instance_id': constants.instance_id, + 'event': event, + } + async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client: + resp = await client.post(url, json=payload) + if resp.status_code == 200: + data = resp.json() + if data.get('code') == 0 and data.get('data', {}).get('survey'): + self._pending_survey = data['data']['survey'] + self.ap.logger.info(f'Survey pending: {self._pending_survey.get("survey_id")}') + except Exception as e: + self.ap.logger.debug(f'Failed to fetch pending survey: {e}') + + def get_pending_survey(self) -> typing.Optional[dict]: + """Return the current pending survey (if any) for the frontend to display.""" + return self._pending_survey + + def clear_pending_survey(self): + """Clear the pending survey (after user responds or dismisses).""" + self._pending_survey = None + + async def submit_response(self, survey_id: str, answers: dict, completed: bool = True) -> bool: + """Submit a survey response to Space.""" + if not self._is_space_configured(): + return False + try: + url = f'{self._space_url}/api/v1/survey/respond' + payload = { + 'survey_id': survey_id, + 'instance_id': constants.instance_id, + 'answers': answers, + 'metadata': { + 'version': constants.semantic_version, + }, + 'completed': completed, + } + async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client: + resp = await client.post(url, json=payload) + if resp.status_code == 200: + self.clear_pending_survey() + return True + except Exception as e: + self.ap.logger.warning(f'Failed to submit survey response: {e}') + return False + + async def dismiss_survey(self, survey_id: str) -> bool: + """Dismiss a survey.""" + if not self._is_space_configured(): + return False + try: + url = f'{self._space_url}/api/v1/survey/dismiss' + payload = { + 'survey_id': survey_id, + 'instance_id': constants.instance_id, + } + async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client: + resp = await client.post(url, json=payload) + if resp.status_code == 200: + self.clear_pending_survey() + return True + except Exception as e: + self.ap.logger.warning(f'Failed to dismiss survey: {e}') + return False diff --git a/web/src/app/home/components/survey/SurveyWidget.tsx b/web/src/app/home/components/survey/SurveyWidget.tsx new file mode 100644 index 00000000..96015b44 --- /dev/null +++ b/web/src/app/home/components/survey/SurveyWidget.tsx @@ -0,0 +1,391 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import type { + SurveyQuestion, + SurveyOption, +} from '@/app/infra/http/BackendClient'; +import { X, ChevronRight, ChevronLeft, MessageSquare } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; + +/** + * Get i18n text from a Record based on browser locale. + */ +function getI18nText(obj?: Record | null): string { + if (!obj) return ''; + const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'; + if (lang.startsWith('zh')) + return obj['zh_Hans'] || obj['en_US'] || Object.values(obj)[0] || ''; + if (lang.startsWith('ja')) + return obj['ja_JP'] || obj['en_US'] || Object.values(obj)[0] || ''; + return obj['en_US'] || Object.values(obj)[0] || ''; +} + +interface SurveyData { + survey_id: string; + version: number; + title: Record; + description: Record; + questions: SurveyQuestion[]; +} + +export default function SurveyWidget() { + const [survey, setSurvey] = useState(null); + const [visible, setVisible] = useState(false); + const [currentStep, setCurrentStep] = useState(0); + const [answers, setAnswers] = useState>({}); + const [otherInputs, setOtherInputs] = useState>({}); + const [submitted, setSubmitted] = useState(false); + const [collapsed, setCollapsed] = useState(false); + + // Poll for pending survey + useEffect(() => { + let timer: NodeJS.Timeout; + let cancelled = false; + + const checkSurvey = async () => { + try { + const resp = await httpClient.getSurveyPending(); + if (!cancelled && resp?.survey) { + setSurvey(resp.survey); + setVisible(true); + } + } catch { + // Silently ignore + } + }; + + // Check after 5 seconds, then every 60 seconds + timer = setTimeout(() => { + checkSurvey(); + timer = setInterval(checkSurvey, 60000) as unknown as NodeJS.Timeout; + }, 5000); + + return () => { + cancelled = true; + clearTimeout(timer); + clearInterval(timer); + }; + }, []); + + const handleDismiss = useCallback(async () => { + if (survey) { + try { + await httpClient.dismissSurvey(survey.survey_id); + } catch { + /* ignore */ + } + } + setVisible(false); + }, [survey]); + + const handleSubmit = useCallback(async () => { + if (!survey) return; + + // Merge "other" text inputs into answers + const finalAnswers = { ...answers }; + for (const [qId, text] of Object.entries(otherInputs)) { + if (text.trim()) { + const current = finalAnswers[qId]; + if (Array.isArray(current)) { + // Replace 'other' with the text + finalAnswers[qId] = (current as string[]).map((v) => + v === 'other' ? `other:${text}` : v, + ); + } else if (current === 'other') { + finalAnswers[qId] = `other:${text}`; + } + } + } + + try { + await httpClient.submitSurveyResponse( + survey.survey_id, + finalAnswers, + true, + ); + setSubmitted(true); + setTimeout(() => setVisible(false), 2000); + } catch { + /* ignore */ + } + }, [survey, answers, otherInputs]); + + const setAnswer = useCallback((qId: string, value: unknown) => { + setAnswers((prev) => ({ ...prev, [qId]: value })); + }, []); + + if (!visible || !survey) return null; + + const questions = survey.questions || []; + const totalSteps = questions.length; + const currentQuestion = questions[currentStep]; + + if (submitted) { + return ( +
+
+
🎉
+

+ {getI18nText({ + zh_Hans: '感谢你的反馈!', + en_US: 'Thanks for your feedback!', + })} +

+
+
+ ); + } + + if (collapsed) { + return ( + + ); + } + + return ( +
+ {/* Header */} +
+
+ + + {getI18nText(survey.title)} + +
+
+ + +
+
+ + {/* Progress */} +
+
+ {questions.map((_, i) => ( +
+ ))} +
+ + {currentStep + 1} / {totalSteps} + +
+ + {/* Question */} +
+

+ {getI18nText(currentQuestion?.title)} +

+ {currentQuestion?.subtitle && ( +

+ {getI18nText(currentQuestion.subtitle)} +

+ )} + +
+ {currentQuestion?.type === 'single_select' && + currentQuestion.options && ( + setAnswer(currentQuestion.id, v)} + otherText={otherInputs[currentQuestion.id] || ''} + onOtherChange={(t) => + setOtherInputs((prev) => ({ + ...prev, + [currentQuestion.id]: t, + })) + } + /> + )} + + {currentQuestion?.type === 'multi_select' && + currentQuestion.options && ( + setAnswer(currentQuestion.id, v)} + otherText={otherInputs[currentQuestion.id] || ''} + onOtherChange={(t) => + setOtherInputs((prev) => ({ + ...prev, + [currentQuestion.id]: t, + })) + } + /> + )} + + {currentQuestion?.type === 'text' && ( +