diff --git a/src/langbot/pkg/api/http/controller/groups/survey.py b/src/langbot/pkg/api/http/controller/groups/survey.py index dcfd7f9ee..2d5882673 100644 --- a/src/langbot/pkg/api/http/controller/groups/survey.py +++ b/src/langbot/pkg/api/http/controller/groups/survey.py @@ -30,6 +30,50 @@ class SurveyRouterGroup(group.RouterGroup): return self.fail(2, 'Failed to submit response') return self.fail(3, 'Survey not available') + @self.route('/feedback', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _feedback(user_email: str) -> str: + """Submit on-demand user feedback from the sidebar.""" + json_data = await quart.request.get_json(silent=True) or {} + content = str(json_data.get('content', '')).strip() + attachments = json_data.get('attachments', []) + page_url = str(json_data.get('page_url', ''))[:2048] + user_agent = str(json_data.get('user_agent', ''))[:512] + + if not content: + return self.fail(1, 'content required') + if len(content) > 5000: + return self.fail(2, 'content too long') + if not isinstance(attachments, list): + return self.fail(3, 'attachments must be an array') + if len(attachments) > 3: + return self.fail(4, 'too many attachments') + + normalized_attachments = [] + for item in attachments: + if not isinstance(item, dict): + continue + data_url = str(item.get('data_url', '')) + mime_type = str(item.get('mime_type', ''))[:128] + name = str(item.get('name', ''))[:255] + if not data_url.startswith('data:image/'): + continue + if len(data_url) > 2_800_000: + return self.fail(5, 'attachment too large') + normalized_attachments.append({'name': name, 'mime_type': mime_type, 'data_url': data_url}) + + if self.ap.survey: + ok = await self.ap.survey.submit_feedback( + content=content, + attachments=normalized_attachments, + page_url=page_url, + user_agent=user_agent, + user_email=user_email, + ) + if ok: + return self.success() + return self.fail(6, 'Failed to submit feedback') + return self.fail(7, 'Survey not available') + @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _dismiss() -> str: """Dismiss survey.""" diff --git a/src/langbot/pkg/survey/manager.py b/src/langbot/pkg/survey/manager.py index bf28404bd..b5ea31cd5 100644 --- a/src/langbot/pkg/survey/manager.py +++ b/src/langbot/pkg/survey/manager.py @@ -159,6 +159,21 @@ class SurveyManager: """Clear the pending survey (after user responds or dismisses).""" self._pending_survey = None + async def _build_base_metadata(self, user_email: str | None = None) -> dict: + metadata = { + 'version': constants.semantic_version, + 'instance_id': constants.instance_id, + } + if user_email: + metadata['login_account'] = user_email + try: + user_obj = await self.ap.user_service.get_user_by_email(user_email) + metadata['account_type'] = getattr(user_obj, 'account_type', '') or 'local' + metadata['space_account_uuid'] = getattr(user_obj, 'space_account_uuid', '') or '' + except Exception: + pass + return metadata + 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(): @@ -169,9 +184,7 @@ class SurveyManager: 'survey_id': survey_id, 'instance_id': constants.instance_id, 'answers': answers, - 'metadata': { - 'version': constants.semantic_version, - }, + 'metadata': await self._build_base_metadata(), 'completed': completed, } async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client: @@ -183,6 +196,41 @@ class SurveyManager: self.ap.logger.warning(f'Failed to submit survey response: {e}') return False + async def submit_feedback( + self, + content: str, + attachments: list[dict], + page_url: str, + user_agent: str, + user_email: str | None = None, + ) -> bool: + """Submit an on-demand user feedback item to Space.""" + if not self._is_space_configured(): + return False + try: + url = f'{self._space_url}/api/v1/survey/feedback' + metadata = await self._build_base_metadata(user_email) + metadata.update( + { + 'page_url': page_url, + 'user_agent': user_agent, + } + ) + payload = { + 'instance_id': constants.instance_id, + 'content': content, + 'attachments': attachments, + 'metadata': metadata, + } + async with httpx.AsyncClient(timeout=httpx.Timeout(30)) as client: + resp = await client.post(url, json=payload) + if resp.status_code == 200: + return True + self.ap.logger.warning(f'Failed to submit feedback: {resp.status_code} {resp.text[:200]}') + except Exception as e: + self.ap.logger.warning(f'Failed to submit feedback: {e}') + return False + async def dismiss_survey(self, survey_id: str) -> bool: """Dismiss a survey.""" if not self._is_space_configured(): diff --git a/web/src/app/home/components/home-sidebar/FeedbackPopover.tsx b/web/src/app/home/components/home-sidebar/FeedbackPopover.tsx new file mode 100644 index 000000000..cbd1bcbf2 --- /dev/null +++ b/web/src/app/home/components/home-sidebar/FeedbackPopover.tsx @@ -0,0 +1,278 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Camera, ImagePlus, Loader2, Paperclip, Send, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +const MAX_ATTACHMENTS = 3; +const MAX_IMAGE_BYTES = 2 * 1024 * 1024; + +type FeedbackAttachment = { + name: string; + mime_type: string; + data_url: string; +}; + +function bytesFromDataUrl(dataUrl: string): number { + const payload = dataUrl.split(',')[1] || ''; + return Math.ceil((payload.length * 3) / 4); +} + +function readImageFile(file: File): Promise { + return new Promise((resolve, reject) => { + if (!file.type.startsWith('image/')) { + reject(new Error('not_image')); + return; + } + if (file.size > MAX_IMAGE_BYTES) { + reject(new Error('too_large')); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = String(reader.result || ''); + if (!dataUrl.startsWith('data:image/')) { + reject(new Error('not_image')); + return; + } + resolve({ + name: file.name || 'pasted-image.png', + mime_type: file.type || 'image/png', + data_url: dataUrl, + }); + }; + reader.onerror = () => reject(reader.error || new Error('read_failed')); + reader.readAsDataURL(file); + }); +} + +async function captureScreen(): Promise { + if (!navigator.mediaDevices?.getDisplayMedia) { + throw new Error('unsupported'); + } + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: false, + }); + try { + const video = document.createElement('video'); + video.srcObject = stream; + await video.play(); + await new Promise((resolve) => requestAnimationFrame(resolve)); + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('canvas_unavailable'); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL('image/png'); + if (bytesFromDataUrl(dataUrl) > MAX_IMAGE_BYTES) { + throw new Error('too_large'); + } + return { + name: `screenshot-${Date.now()}.png`, + mime_type: 'image/png', + data_url: dataUrl, + }; + } finally { + stream.getTracks().forEach((track) => track.stop()); + } +} + +export function FeedbackPopoverContent({ + onSubmitted, +}: { + onSubmitted?: () => void; +}) { + const { t } = useTranslation(); + const [content, setContent] = useState(''); + const [attachments, setAttachments] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [capturing, setCapturing] = useState(false); + const fileInputRef = useRef(null); + + const addFiles = useCallback( + async (files: File[]) => { + const slots = MAX_ATTACHMENTS - attachments.length; + if (slots <= 0) { + toast.error(t('feedback.tooManyImages')); + return; + } + const picked = files.slice(0, slots); + const next: FeedbackAttachment[] = []; + for (const file of picked) { + try { + next.push(await readImageFile(file)); + } catch (error) { + const msg = error instanceof Error ? error.message : ''; + toast.error( + msg === 'too_large' + ? t('feedback.imageTooLarge') + : t('feedback.imageOnly'), + ); + } + } + if (next.length > 0) { + setAttachments((prev) => [...prev, ...next].slice(0, MAX_ATTACHMENTS)); + } + }, + [attachments.length, t], + ); + + useEffect(() => { + const onPaste = (event: ClipboardEvent) => { + const files = Array.from(event.clipboardData?.files || []).filter( + (file) => file.type.startsWith('image/'), + ); + if (files.length > 0) { + event.preventDefault(); + void addFiles(files); + } + }; + window.addEventListener('paste', onPaste); + return () => window.removeEventListener('paste', onPaste); + }, [addFiles]); + + const handleCapture = async () => { + try { + if (attachments.length >= MAX_ATTACHMENTS) { + toast.error(t('feedback.tooManyImages')); + return; + } + setCapturing(true); + const screenshot = await captureScreen(); + setAttachments((prev) => [...prev, screenshot].slice(0, MAX_ATTACHMENTS)); + } catch (error) { + const msg = error instanceof Error ? error.message : ''; + toast.error( + msg === 'too_large' + ? t('feedback.imageTooLarge') + : t('feedback.screenshotFailed'), + ); + } finally { + setCapturing(false); + } + }; + + const handleSubmit = async () => { + const trimmed = content.trim(); + if (!trimmed) { + toast.error(t('feedback.contentRequired')); + return; + } + try { + setSubmitting(true); + await httpClient.submitFeedback({ + content: trimmed, + attachments, + page_url: window.location.href, + user_agent: navigator.userAgent, + }); + toast.success(t('feedback.submitSuccess')); + setContent(''); + setAttachments([]); + onSubmitted?.(); + } catch { + toast.error(t('feedback.submitFailed')); + } finally { + setSubmitting(false); + } + }; + + return ( +
e.stopPropagation()}> +
+
{t('feedback.title')}
+

+ {t('feedback.description')} +

+
+