fix(web): keep feedback dialog interactive

This commit is contained in:
dadachann
2026-06-24 05:27:39 -04:00
parent 76471af179
commit 85d8d9304e
12 changed files with 134 additions and 132 deletions
@@ -1,3 +1,5 @@
import base64
import quart
from .. import group
@@ -36,8 +38,6 @@ class SurveyRouterGroup(group.RouterGroup):
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')
@@ -57,7 +57,11 @@ class SurveyRouterGroup(group.RouterGroup):
name = str(item.get('name', ''))[:255]
if not data_url.startswith('data:image/'):
continue
if len(data_url) > 2_800_000:
try:
payload = data_url.split(',', 1)[1]
if len(base64.b64decode(payload, validate=True)) > 1024 * 1024:
return self.fail(5, 'attachment too large')
except Exception:
return self.fail(5, 'attachment too large')
normalized_attachments.append({'name': name, 'mime_type': mime_type, 'data_url': data_url})
@@ -65,8 +69,6 @@ class SurveyRouterGroup(group.RouterGroup):
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:
-8
View File
@@ -200,8 +200,6 @@ class SurveyManager:
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."""
@@ -210,12 +208,6 @@ class SurveyManager:
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,
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Camera, ImagePlus, Loader2, Paperclip, Send, X } from 'lucide-react';
import { ImagePlus, Loader2, Paperclip, Send, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -7,7 +7,7 @@ import { Textarea } from '@/components/ui/textarea';
import { httpClient } from '@/app/infra/http/HttpClient';
const MAX_ATTACHMENTS = 3;
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
const MAX_IMAGE_BYTES = 1024 * 1024;
type FeedbackAttachment = {
name: string;
@@ -15,11 +15,6 @@ type FeedbackAttachment = {
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<FeedbackAttachment> {
return new Promise((resolve, reject) => {
if (!file.type.startsWith('image/')) {
@@ -48,38 +43,7 @@ function readImageFile(file: File): Promise<FeedbackAttachment> {
});
}
async function captureScreen(): Promise<FeedbackAttachment> {
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());
}
}
const FEEDBACK_I18N_PREFIX = 'monitoring.feedback';
export function FeedbackPopoverContent({
onSubmitted,
@@ -87,17 +51,20 @@ export function FeedbackPopoverContent({
onSubmitted?: () => void;
}) {
const { t } = useTranslation();
const tf = useCallback(
(key: string) => t(`${FEEDBACK_I18N_PREFIX}.${key}`),
[t],
);
const [content, setContent] = useState('');
const [attachments, setAttachments] = useState<FeedbackAttachment[]>([]);
const [submitting, setSubmitting] = useState(false);
const [capturing, setCapturing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const addFiles = useCallback(
async (files: File[]) => {
const slots = MAX_ATTACHMENTS - attachments.length;
if (slots <= 0) {
toast.error(t('feedback.tooManyImages'));
toast.error(tf('tooManyImages'));
return;
}
const picked = files.slice(0, slots);
@@ -108,9 +75,7 @@ export function FeedbackPopoverContent({
} catch (error) {
const msg = error instanceof Error ? error.message : '';
toast.error(
msg === 'too_large'
? t('feedback.imageTooLarge')
: t('feedback.imageOnly'),
msg === 'too_large' ? tf('imageTooLarge') : tf('imageOnly'),
);
}
}
@@ -118,7 +83,7 @@ export function FeedbackPopoverContent({
setAttachments((prev) => [...prev, ...next].slice(0, MAX_ATTACHMENTS));
}
},
[attachments.length, t],
[attachments.length, tf],
);
useEffect(() => {
@@ -135,31 +100,10 @@ export function FeedbackPopoverContent({
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'));
toast.error(tf('contentRequired'));
return;
}
try {
@@ -167,34 +111,32 @@ export function FeedbackPopoverContent({
await httpClient.submitFeedback({
content: trimmed,
attachments,
page_url: window.location.href,
user_agent: navigator.userAgent,
});
toast.success(t('feedback.submitSuccess'));
toast.success(tf('submitSuccess'));
setContent('');
setAttachments([]);
onSubmitted?.();
} catch {
toast.error(t('feedback.submitFailed'));
toast.error(tf('submitFailed'));
} finally {
setSubmitting(false);
}
};
return (
<div className="w-80 space-y-3 p-1" onClick={(e) => e.stopPropagation()}>
<div className="space-y-3" onClick={(e) => e.stopPropagation()}>
<div>
<div className="text-sm font-medium">{t('feedback.title')}</div>
<div className="text-sm font-medium">{tf('title')}</div>
<p className="mt-1 text-xs text-muted-foreground">
{t('feedback.description')}
{tf('description')}
</p>
</div>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t('feedback.placeholder')}
placeholder={tf('placeholder')}
maxLength={5000}
className="min-h-28 resize-none text-sm"
className="min-h-32 resize-none text-sm"
/>
<div className="flex flex-wrap gap-2">
{attachments.map((item, index) => (
@@ -213,7 +155,7 @@ export function FeedbackPopoverContent({
setAttachments((prev) => prev.filter((_, i) => i !== index))
}
className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white"
aria-label={t('feedback.removeImage')}
aria-label={tf('removeImage')}
>
<X className="size-3" />
</button>
@@ -240,21 +182,7 @@ export function FeedbackPopoverContent({
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="mr-1 size-4" />
{t('feedback.attachImage')}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCapture}
disabled={capturing}
>
{capturing ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Camera className="mr-1 size-4" />
)}
{t('feedback.screenshot')}
{tf('attachImage')}
</Button>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -268,10 +196,10 @@ export function FeedbackPopoverContent({
) : (
<Send className="mr-2 size-4" />
)}
{t('feedback.submit')}
{tf('submit')}
</Button>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{t('feedback.privacyHint')}
{tf('privacyHint')}
</p>
</div>
);
@@ -1570,6 +1570,7 @@ export default function HomeSidebar({
);
const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
@@ -2040,25 +2041,15 @@ export default function HomeSidebar({
<CircleHelp />
{t('common.helpDocs')}
</DropdownMenuItem>
<Popover>
<PopoverTrigger asChild>
<DropdownMenuItem
onSelect={(event) => event.preventDefault()}
>
<Lightbulb />
{t('common.featureRequest')}
</DropdownMenuItem>
</PopoverTrigger>
<PopoverContent
side={isMobile ? 'bottom' : 'right'}
align="start"
className="w-auto p-3"
>
<FeedbackPopoverContent
onSubmitted={() => setUserMenuOpen(false)}
/>
</PopoverContent>
</Popover>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
setFeedbackOpen(true);
}}
>
<Lightbulb />
{t('common.featureRequest')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
window.open(
@@ -2105,6 +2096,18 @@ export default function HomeSidebar({
</SidebarFooter>
</Sidebar>
<Dialog open={feedbackOpen} onOpenChange={setFeedbackOpen}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-[380px]">
<DialogHeader className="sr-only">
<DialogTitle>{t('monitoring.feedback.title')}</DialogTitle>
<DialogDescription>
{t('monitoring.feedback.description')}
</DialogDescription>
</DialogHeader>
<FeedbackPopoverContent onSubmitted={() => setFeedbackOpen(false)} />
</DialogContent>
</Dialog>
<SettingsDialog
open={settingsOpen}
onOpenChange={handleSettingsOpenChange}
-2
View File
@@ -1334,8 +1334,6 @@ export class BackendClient extends BaseHttpClient {
public submitFeedback(data: {
content: string;
page_url?: string;
user_agent?: string;
attachments?: Array<{
name: string;
mime_type: string;
+2 -2
View File
@@ -1363,7 +1363,7 @@ const enUS = {
platform: 'Platform',
exportFeedback: 'Export Feedback',
description:
'Tell us what went wrong or what could be better. Instance UUID, login account, and page info are included for diagnosis.',
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
@@ -1372,7 +1372,7 @@ const enUS = {
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 2MB',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
+16
View File
@@ -1395,6 +1395,22 @@ const esES = {
inaccurateReasons: 'Razones de inexactitud',
platform: 'Plataforma',
exportFeedback: 'Exportar comentarios',
description:
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
submit: 'Submit feedback',
privacyHint:
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
submitFailed: 'Failed to submit feedback. Please try again later.',
removeImage: 'Remove image',
},
queries: {
title: 'Consultas',
+16
View File
@@ -1371,6 +1371,22 @@ const ruRU = {
inaccurateReasons: 'Причины неточности',
platform: 'Платформа',
exportFeedback: 'Экспорт отзывов',
description:
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
submit: 'Submit feedback',
privacyHint:
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
submitFailed: 'Failed to submit feedback. Please try again later.',
removeImage: 'Remove image',
},
queries: {
title: 'Запросы',
+16
View File
@@ -1340,6 +1340,22 @@ const thTH = {
inaccurateReasons: 'เหตุผลที่ไม่ถูกต้อง',
platform: 'แพลตฟอร์ม',
exportFeedback: 'ส่งออกความคิดเห็น',
description:
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
submit: 'Submit feedback',
privacyHint:
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
submitFailed: 'Failed to submit feedback. Please try again later.',
removeImage: 'Remove image',
},
queries: {
title: 'คำค้นหา',
+16
View File
@@ -1364,6 +1364,22 @@ const viVN = {
inaccurateReasons: 'Lý do không chính xác',
platform: 'Nền tảng',
exportFeedback: 'Xuất phản hồi',
description:
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
submit: 'Submit feedback',
privacyHint:
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
submitFailed: 'Failed to submit feedback. Please try again later.',
removeImage: 'Remove image',
},
queries: {
title: 'Truy vấn',
+2 -2
View File
@@ -1302,7 +1302,7 @@ const zhHans = {
platform: '平台',
exportFeedback: '导出反馈',
description:
'告诉我们遇到的问题或想要的改进。提交时会附带实例 UUID登录账号和当前页面信息,方便定位。',
'告诉我们遇到的问题或想要的改进。提交时会附带实例 UUID登录账号,方便定位。',
placeholder: '请描述你的建议、问题或复现步骤...',
attachImage: '添加图片',
screenshot: '截图',
@@ -1310,7 +1310,7 @@ const zhHans = {
privacyHint: '请勿提交敏感密钥、密码或私人聊天内容。',
contentRequired: '请先填写反馈内容',
imageOnly: '仅支持图片附件',
imageTooLarge: '单张图片不能超过 2MB',
imageTooLarge: '单张图片不能超过 1MB',
tooManyImages: '最多添加 3 张图片',
screenshotFailed: '截图失败,请尝试粘贴或上传图片',
submitSuccess: '反馈已提交,感谢!',
+15
View File
@@ -1300,6 +1300,21 @@ const zhHant = {
inaccurateReasons: '不準確原因',
platform: '平台',
exportFeedback: '匯出反饋',
description:
'告訴我們遇到的問題或想要的改進。提交時會附帶實例 UUID 和登入帳號,方便定位。',
placeholder: '請描述你的建議、問題或重現步驟...',
attachImage: '新增圖片',
screenshot: '截圖',
submit: '提交反饋',
privacyHint: '請勿提交敏感金鑰、密碼或私人聊天內容。',
contentRequired: '請先填寫反饋內容',
imageOnly: '僅支援圖片附件',
imageTooLarge: '單張圖片不能超過 1MB',
tooManyImages: '最多新增 3 張圖片',
screenshotFailed: '截圖失敗,請嘗試貼上或上傳圖片',
submitSuccess: '反饋已提交,感謝!',
submitFailed: '反饋提交失敗,請稍後再試',
removeImage: '移除圖片',
},
messageDetails: {
noData: '此查詢沒有LLM調用或錯誤記錄',