Compare commits

..

5 Commits

Author SHA1 Message Date
dadachann 85d8d9304e fix(web): keep feedback dialog interactive 2026-06-24 10:10:19 -04:00
Hyu 76471af179 feat(web): add sidebar feedback popover
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-06-24 16:43:50 +08:00
RockChinQ 59b2a7cd51 fix(monitoring): hide disabled box status on cloud 2026-06-23 06:40:05 -04:00
RockChinQ a43978ff24 chore(release): bump version to 4.10.4 2026-06-22 21:15:53 -04:00
RockChinQ e3417dd20b fix(release): derive package version from metadata 2026-06-22 21:10:33 -04:00
21 changed files with 664 additions and 179 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.3" version = "4.10.4"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
+3 -1
View File
@@ -1,3 +1,5 @@
"""LangBot - Production-grade platform for building agentic IM bots""" """LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.10.2' from importlib.metadata import version
__version__ = version('langbot')
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from langbot.pkg.utils import constants
from .. import group from .. import group
from .box_visibility import should_hide_box_runtime_status
@group.group_class('box', '/api/v1/box') @group.group_class('box', '/api/v1/box')
@@ -9,6 +12,7 @@ class BoxRouterGroup(group.RouterGroup):
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str: async def _() -> str:
status = await self.ap.box_service.get_status() status = await self.ap.box_service.get_status()
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
return self.success(data=status) return self.success(data=status)
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
@@ -0,0 +1,5 @@
from __future__ import annotations
def should_hide_box_runtime_status(edition: str, box_enabled: bool | None) -> bool:
return edition == 'cloud' and box_enabled is False
@@ -1,3 +1,5 @@
import base64
import quart import quart
from .. import group from .. import group
@@ -30,6 +32,50 @@ class SurveyRouterGroup(group.RouterGroup):
return self.fail(2, 'Failed to submit response') return self.fail(2, 'Failed to submit response')
return self.fail(3, 'Survey not available') 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', [])
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
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})
if self.ap.survey:
ok = await self.ap.survey.submit_feedback(
content=content,
attachments=normalized_attachments,
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) @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _dismiss() -> str: async def _dismiss() -> str:
"""Dismiss survey.""" """Dismiss survey."""
+43 -3
View File
@@ -159,6 +159,21 @@ class SurveyManager:
"""Clear the pending survey (after user responds or dismisses).""" """Clear the pending survey (after user responds or dismisses)."""
self._pending_survey = None 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: async def submit_response(self, survey_id: str, answers: dict, completed: bool = True) -> bool:
"""Submit a survey response to Space.""" """Submit a survey response to Space."""
if not self._is_space_configured(): if not self._is_space_configured():
@@ -169,9 +184,7 @@ class SurveyManager:
'survey_id': survey_id, 'survey_id': survey_id,
'instance_id': constants.instance_id, 'instance_id': constants.instance_id,
'answers': answers, 'answers': answers,
'metadata': { 'metadata': await self._build_base_metadata(),
'version': constants.semantic_version,
},
'completed': completed, 'completed': completed,
} }
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
@@ -183,6 +196,33 @@ class SurveyManager:
self.ap.logger.warning(f'Failed to submit survey response: {e}') self.ap.logger.warning(f'Failed to submit survey response: {e}')
return False return False
async def submit_feedback(
self,
content: str,
attachments: list[dict],
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)
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: async def dismiss_survey(self, survey_id: str) -> bool:
"""Dismiss a survey.""" """Dismiss a survey."""
if not self._is_space_configured(): if not self._is_space_configured():
@@ -0,0 +1,18 @@
from __future__ import annotations
import pytest
from langbot.pkg.api.http.controller.groups.box_visibility import should_hide_box_runtime_status
@pytest.mark.parametrize(
('edition', 'box_enabled', 'expected'),
[
('cloud', False, True),
('cloud', True, False),
('cloud', None, False),
('community', False, False),
],
)
def test_should_hide_box_runtime_status(edition, box_enabled, expected):
assert should_hide_box_runtime_status(edition, box_enabled) is expected
Generated
+1 -1
View File
@@ -2008,7 +2008,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.10.3" version = "4.10.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useRef, useState } from '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';
import { Textarea } from '@/components/ui/textarea';
import { httpClient } from '@/app/infra/http/HttpClient';
const MAX_ATTACHMENTS = 3;
const MAX_IMAGE_BYTES = 1024 * 1024;
type FeedbackAttachment = {
name: string;
mime_type: string;
data_url: string;
};
function readImageFile(file: File): Promise<FeedbackAttachment> {
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);
});
}
const FEEDBACK_I18N_PREFIX = 'monitoring.feedback';
export function FeedbackPopoverContent({
onSubmitted,
}: {
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 fileInputRef = useRef<HTMLInputElement>(null);
const addFiles = useCallback(
async (files: File[]) => {
const slots = MAX_ATTACHMENTS - attachments.length;
if (slots <= 0) {
toast.error(tf('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' ? tf('imageTooLarge') : tf('imageOnly'),
);
}
}
if (next.length > 0) {
setAttachments((prev) => [...prev, ...next].slice(0, MAX_ATTACHMENTS));
}
},
[attachments.length, tf],
);
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 handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed) {
toast.error(tf('contentRequired'));
return;
}
try {
setSubmitting(true);
await httpClient.submitFeedback({
content: trimmed,
attachments,
});
toast.success(tf('submitSuccess'));
setContent('');
setAttachments([]);
onSubmitted?.();
} catch {
toast.error(tf('submitFailed'));
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-3" onClick={(e) => e.stopPropagation()}>
<div>
<div className="text-sm font-medium">{tf('title')}</div>
<p className="mt-1 text-xs text-muted-foreground">
{tf('description')}
</p>
</div>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={tf('placeholder')}
maxLength={5000}
className="min-h-32 resize-none text-sm"
/>
<div className="flex flex-wrap gap-2">
{attachments.map((item, index) => (
<div
key={`${item.name}-${index}`}
className="relative size-16 overflow-hidden rounded-md border"
>
<img
src={item.data_url}
alt={item.name}
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() =>
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={tf('removeImage')}
>
<X className="size-3" />
</button>
</div>
))}
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
void addFiles(Array.from(e.target.files || []));
e.target.value = '';
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="mr-1 size-4" />
{tf('attachImage')}
</Button>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Paperclip className="size-3" />
{attachments.length}/{MAX_ATTACHMENTS}
</span>
</div>
<Button className="w-full" onClick={handleSubmit} disabled={submitting}>
{submitting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Send className="mr-2 size-4" />
)}
{tf('submit')}
</Button>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{tf('privacyHint')}
</p>
</div>
);
}
@@ -100,6 +100,7 @@ import {
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import { FeedbackPopoverContent } from './FeedbackPopover';
// Compare two version strings, returns true if v1 > v2 // Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean { function compareVersions(v1: string, v2: string): boolean {
@@ -1569,6 +1570,7 @@ export default function HomeSidebar({
); );
const [hasNewVersion, setHasNewVersion] = useState(false); const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false); const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>(''); const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(null); const [starCount, setStarCount] = useState<number | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
@@ -2041,10 +2043,8 @@ export default function HomeSidebar({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
window.open( setUserMenuOpen(false);
'https://github.com/langbot-app/LangBot/issues', setFeedbackOpen(true);
'_blank',
);
}} }}
> >
<Lightbulb /> <Lightbulb />
@@ -2096,6 +2096,18 @@ export default function HomeSidebar({
</SidebarFooter> </SidebarFooter>
</Sidebar> </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 <SettingsDialog
open={settingsOpen} open={settingsOpen}
onOpenChange={handleSettingsOpenChange} onOpenChange={handleSettingsOpenChange}
@@ -65,11 +65,13 @@ export default function SystemStatusCard({
const fetchStatus = useCallback(async () => { const fetchStatus = useCallback(async () => {
try { try {
const [plugin, box, sessions] = await Promise.all([ const [plugin, box] = await Promise.all([
httpClient.getPluginSystemStatus().catch(() => null), httpClient.getPluginSystemStatus().catch(() => null),
httpClient.getBoxStatus().catch(() => null), httpClient.getBoxStatus().catch(() => null),
httpClient.getBoxSessions().catch(() => [] as BoxSessionInfo[]),
]); ]);
const sessions = box?.hidden
? []
: await httpClient.getBoxSessions().catch(() => [] as BoxSessionInfo[]);
setPluginStatus(plugin); setPluginStatus(plugin);
setBoxStatus(box); setBoxStatus(box);
setBoxSessions(sessions); setBoxSessions(sessions);
@@ -95,6 +97,7 @@ export default function SystemStatusCard({
: 'failed' : 'failed'
: null; : null;
const boxOk = boxStatus ? boxStatus.available : null; const boxOk = boxStatus ? boxStatus.available : null;
const hideBoxRuntime = boxStatus?.hidden === true;
// Box has three observable states: connected (ok), disabled by config // Box has three observable states: connected (ok), disabled by config
// (enabled = false → distinct gray dot + "disabled" hint), and configured // (enabled = false → distinct gray dot + "disabled" hint), and configured
// but failed (red dot + connector_error). The dashboard must distinguish // but failed (red dot + connector_error). The dashboard must distinguish
@@ -152,11 +155,13 @@ export default function SystemStatusCard({
<Plug className="w-3.5 h-3.5 text-muted-foreground" /> <Plug className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm">{t('monitoring.pluginRuntime')}</span> <span className="text-sm">{t('monitoring.pluginRuntime')}</span>
</div> </div>
<div className="flex items-center gap-2"> {!hideBoxRuntime && (
<StatusDot state={boxState} /> <div className="flex items-center gap-2">
<Box className="w-3.5 h-3.5 text-muted-foreground" /> <StatusDot state={boxState} />
<span className="text-sm">{t('monitoring.boxRuntime')}</span> <Box className="w-3.5 h-3.5 text-muted-foreground" />
</div> <span className="text-sm">{t('monitoring.boxRuntime')}</span>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -214,181 +219,189 @@ export default function SystemStatusCard({
</div> </div>
</div> </div>
<div className="border-t" /> {!hideBoxRuntime && (
<>
<div className="border-t" />
{/* Box Runtime */} {/* Box Runtime */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Box className="w-4 h-4 text-muted-foreground" /> <Box className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> <span className="text-sm font-semibold">
{t('monitoring.boxRuntime')} {t('monitoring.boxRuntime')}
</span> </span>
</div>
<div className="ml-6 text-sm space-y-1">
<div className="flex items-center gap-1.5">
{boxState === 'ok' ? (
<CircleCheck className="w-4 h-4 text-green-600" />
) : (
<CircleX
className={
boxState === 'disabled'
? 'w-4 h-4 text-muted-foreground'
: 'w-4 h-4 text-red-500'
}
/>
)}
<span
className={
boxState === 'ok'
? 'text-green-600 font-medium'
: boxState === 'disabled'
? 'text-muted-foreground font-medium'
: 'text-red-500 font-medium'
}
>
{boxState === 'ok'
? t('monitoring.connected')
: boxState === 'disabled'
? t('monitoring.disabled')
: t('monitoring.disconnected')}
</span>
</div>
{boxState === 'disabled' && (
<p className="text-muted-foreground text-xs">
{t('monitoring.boxDisabled')}
</p>
)}
{boxState === 'failed' && boxStatus?.connector_error && (
<p className="text-red-400 text-xs break-all">
{boxStatus.connector_error}
</p>
)}
{boxStatus && (
<div className="text-muted-foreground text-xs space-y-0.5">
{boxStatus.backend && (
<p>
{t('monitoring.boxBackend')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.backend.name}
</span>
</p>
)}
<p>
{t('monitoring.boxProfile')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.profile}
</span>
</p>
{boxOk && boxStatus.active_sessions !== undefined && (
<p>
{t('monitoring.boxSandboxes')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.active_sessions}
</span>
</p>
)}
</div> </div>
)} <div className="ml-6 text-sm space-y-1">
<div className="flex items-center gap-1.5">
{/* Active Sandboxes */} {boxState === 'ok' ? (
{boxSessions.length > 0 && ( <CircleCheck className="w-4 h-4 text-green-600" />
<div className="mt-3 space-y-2"> ) : (
{boxSessions.map((session) => ( <CircleX
<div className={
key={session.session_id} boxState === 'disabled'
className="rounded-lg border p-3 space-y-2" ? 'w-4 h-4 text-muted-foreground'
: 'w-4 h-4 text-red-500'
}
/>
)}
<span
className={
boxState === 'ok'
? 'text-green-600 font-medium'
: boxState === 'disabled'
? 'text-muted-foreground font-medium'
: 'text-red-500 font-medium'
}
> >
<div className="flex items-center gap-1.5 min-w-0"> {boxState === 'ok'
<Container className="w-4 h-4 text-muted-foreground flex-shrink-0" /> ? t('monitoring.connected')
<Tooltip> : boxState === 'disabled'
<TooltipTrigger asChild> ? t('monitoring.disabled')
<span className="font-mono font-semibold text-foreground truncate text-sm"> : t('monitoring.disconnected')}
{session.session_id} </span>
</span> </div>
</TooltipTrigger> {boxState === 'disabled' && (
<TooltipContent> <p className="text-muted-foreground text-xs">
{session.session_id} {t('monitoring.boxDisabled')}
</TooltipContent> </p>
</Tooltip> )}
</div> {boxState === 'failed' && boxStatus?.connector_error && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs"> <p className="text-red-400 text-xs break-all">
<div className="flex items-center gap-1.5 text-muted-foreground min-w-0"> {boxStatus.connector_error}
<Image className="w-3 h-3 flex-shrink-0" /> </p>
<Tooltip> )}
<TooltipTrigger asChild> {boxStatus && (
<span className="text-foreground font-mono truncate"> <div className="text-muted-foreground text-xs space-y-0.5">
{session.image} {boxStatus.backend && (
</span> <p>
</TooltipTrigger> {t('monitoring.boxBackend')}:{' '}
<TooltipContent>{session.image}</TooltipContent> <span className="text-foreground font-mono">
</Tooltip> {boxStatus.backend.name}
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<HardDrive className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.backend_name}
</span> </span>
</div> </p>
<div className="flex items-center gap-1.5 text-muted-foreground"> )}
<Cpu className="w-3 h-3 flex-shrink-0" /> <p>
<span className="text-foreground"> {t('monitoring.boxProfile')}:{' '}
{session.cpus} CPU / {session.memory_mb} MB <span className="text-foreground font-mono">
{boxStatus.profile}
</span>
</p>
{boxOk && boxStatus.active_sessions !== undefined && (
<p>
{t('monitoring.boxSandboxes')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.active_sessions}
</span> </span>
</div> </p>
<div className="flex items-center gap-1.5 text-muted-foreground"> )}
<Network className="w-3 h-3 flex-shrink-0" /> </div>
<span className="text-foreground"> )}
{session.network}
</span> {/* Active Sandboxes */}
</div> {boxSessions.length > 0 && (
{session.host_path && ( <div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-muted-foreground col-span-2 min-w-0"> {boxSessions.map((session) => (
<FolderOpen className="w-3 h-3 flex-shrink-0" /> <div
key={session.session_id}
className="rounded-lg border p-3 space-y-2"
>
<div className="flex items-center gap-1.5 min-w-0">
<Container className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="text-foreground font-mono truncate"> <span className="font-mono font-semibold text-foreground truncate text-sm">
{session.host_path} : {session.mount_path}{' '} {session.session_id}
<span className="text-muted-foreground">
({session.host_path_mode})
</span>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{session.host_path} : {session.mount_path} ( {session.session_id}
{session.host_path_mode})
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
)} <div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground"> <div className="flex items-center gap-1.5 text-muted-foreground min-w-0">
<Clock className="w-3 h-3 flex-shrink-0" /> <Image className="w-3 h-3 flex-shrink-0" />
<span> <Tooltip>
{t('monitoring.boxSessionCreated')}:{' '} <TooltipTrigger asChild>
<span className="text-foreground"> <span className="text-foreground font-mono truncate">
{new Date( {session.image}
session.created_at, </span>
).toLocaleString()} </TooltipTrigger>
</span> <TooltipContent>
</span> {session.image}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<HardDrive className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.backend_name}
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Cpu className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.cpus} CPU / {session.memory_mb} MB
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Network className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.network}
</span>
</div>
{session.host_path && (
<div className="flex items-center gap-1.5 text-muted-foreground col-span-2 min-w-0">
<FolderOpen className="w-3 h-3 flex-shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-foreground font-mono truncate">
{session.host_path} :{' '}
{session.mount_path}{' '}
<span className="text-muted-foreground">
({session.host_path_mode})
</span>
</span>
</TooltipTrigger>
<TooltipContent>
{session.host_path} :{' '}
{session.mount_path} (
{session.host_path_mode})
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionCreated')}:{' '}
<span className="text-foreground">
{new Date(
session.created_at,
).toLocaleString()}
</span>
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionLastUsed')}:{' '}
<span className="text-foreground">
{new Date(
session.last_used_at,
).toLocaleString()}
</span>
</span>
</div>
</div>
</div> </div>
<div className="flex items-center gap-1.5 text-muted-foreground"> ))}
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionLastUsed')}:{' '}
<span className="text-foreground">
{new Date(
session.last_used_at,
).toLocaleString()}
</span>
</span>
</div>
</div>
</div> </div>
))} )}
</div> </div>
)} </div>
</div> </>
</div> )}
</div> </div>
</TooltipProvider> </TooltipProvider>
</DialogContent> </DialogContent>
+2
View File
@@ -373,6 +373,8 @@ export interface ApiRespPluginSystemStatus {
export interface ApiRespBoxStatus { export interface ApiRespBoxStatus {
available: boolean; available: boolean;
/** UI hint: hide the Box runtime status surface for this deployment. */
hidden?: boolean;
/** Whether ``box.enabled`` is true in config. When false, the sandbox /** Whether ``box.enabled`` is true in config. When false, the sandbox
* is deliberately disabled — distinct from "configured but failed". */ * is deliberately disabled — distinct from "configured but failed". */
enabled?: boolean; enabled?: boolean;
+11
View File
@@ -1332,6 +1332,17 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/survey/dismiss', { survey_id: surveyId }); return this.post('/api/v1/survey/dismiss', { survey_id: surveyId });
} }
public submitFeedback(data: {
content: string;
attachments?: Array<{
name: string;
mime_type: string;
data_url: string;
}>;
}): Promise<object> {
return this.post('/api/v1/survey/feedback', data);
}
// ============ Skills API ============ // ============ Skills API ============
public getSkills(): Promise<ApiRespSkills> { public getSkills(): Promise<ApiRespSkills> {
+17 -1
View File
@@ -35,7 +35,7 @@ const enUS = {
emptyPassword: 'Please enter your password', emptyPassword: 'Please enter your password',
language: 'Language', language: 'Language',
helpDocs: 'Get Help', helpDocs: 'Get Help',
featureRequest: 'Feature Request', featureRequest: 'Feedback',
starOnGitHub: 'Star on GitHub', starOnGitHub: 'Star on GitHub',
joinDiscord: 'Join our Discord', joinDiscord: 'Join our Discord',
create: 'Create', create: 'Create',
@@ -1362,6 +1362,22 @@ const enUS = {
inaccurateReasons: 'Inaccurate Reasons', inaccurateReasons: 'Inaccurate Reasons',
platform: 'Platform', platform: 'Platform',
exportFeedback: 'Export Feedback', exportFeedback: 'Export Feedback',
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: { queries: {
title: 'Queries', title: 'Queries',
+16
View File
@@ -1395,6 +1395,22 @@ const esES = {
inaccurateReasons: 'Razones de inexactitud', inaccurateReasons: 'Razones de inexactitud',
platform: 'Plataforma', platform: 'Plataforma',
exportFeedback: 'Exportar comentarios', 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: { queries: {
title: 'Consultas', title: 'Consultas',
+17 -1
View File
@@ -36,7 +36,7 @@ const jaJP = {
emptyPassword: 'パスワードを入力してください', emptyPassword: 'パスワードを入力してください',
language: '言語', language: '言語',
helpDocs: 'ヘルプドキュメント', helpDocs: 'ヘルプドキュメント',
featureRequest: '機能リクエスト', featureRequest: 'フィードバック',
starOnGitHub: 'GitHubでStarする', starOnGitHub: 'GitHubでStarする',
joinDiscord: 'Discord に参加', joinDiscord: 'Discord に参加',
create: '作成', create: '作成',
@@ -1368,6 +1368,22 @@ const jaJP = {
inaccurateReasons: '不正確な理由', inaccurateReasons: '不正確な理由',
platform: 'プラットフォーム', platform: 'プラットフォーム',
exportFeedback: 'フィードバックをエクスポート', exportFeedback: 'フィードバックをエクスポート',
description:
'問題点や改善案を教えてください。診断のため、インスタンス UUID、ログインアカウント、ページ情報も送信されます。',
placeholder: '提案、問題、再現手順を入力してください...',
attachImage: '画像を追加',
screenshot: 'スクリーンショット',
submit: '送信',
privacyHint: '秘密鍵、パスワード、個人的な会話内容は含めないでください。',
contentRequired: 'フィードバック内容を入力してください',
imageOnly: '画像のみ添付できます',
imageTooLarge: '画像は 1 枚 2MB 未満にしてください',
tooManyImages: '画像は最大 3 枚まで添付できます',
screenshotFailed:
'スクリーンショットに失敗しました。貼り付けまたはアップロードを試してください。',
submitSuccess: 'フィードバックを送信しました。ありがとうございます!',
submitFailed: '送信に失敗しました。後でもう一度お試しください。',
removeImage: '画像を削除',
}, },
messageDetails: { messageDetails: {
noData: 'このクエリにはLLM呼び出しやエラーがありません', noData: 'このクエリにはLLM呼び出しやエラーがありません',
+16
View File
@@ -1371,6 +1371,22 @@ const ruRU = {
inaccurateReasons: 'Причины неточности', inaccurateReasons: 'Причины неточности',
platform: 'Платформа', platform: 'Платформа',
exportFeedback: 'Экспорт отзывов', 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: { queries: {
title: 'Запросы', title: 'Запросы',
+16
View File
@@ -1340,6 +1340,22 @@ const thTH = {
inaccurateReasons: 'เหตุผลที่ไม่ถูกต้อง', inaccurateReasons: 'เหตุผลที่ไม่ถูกต้อง',
platform: 'แพลตฟอร์ม', platform: 'แพลตฟอร์ม',
exportFeedback: 'ส่งออกความคิดเห็น', 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: { queries: {
title: 'คำค้นหา', title: 'คำค้นหา',
+16
View File
@@ -1364,6 +1364,22 @@ const viVN = {
inaccurateReasons: 'Lý do không chính xác', inaccurateReasons: 'Lý do không chính xác',
platform: 'Nền tảng', platform: 'Nền tảng',
exportFeedback: 'Xuất phản hồi', 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: { queries: {
title: 'Truy vấn', title: 'Truy vấn',
+16 -1
View File
@@ -34,7 +34,7 @@ const zhHans = {
emptyPassword: '请输入密码', emptyPassword: '请输入密码',
language: '语言', language: '语言',
helpDocs: '帮助文档', helpDocs: '帮助文档',
featureRequest: '需求建议', featureRequest: '建议反馈',
starOnGitHub: '在 GitHub 上 Star', starOnGitHub: '在 GitHub 上 Star',
joinDiscord: '加入 Discord 社区', joinDiscord: '加入 Discord 社区',
create: '创建', create: '创建',
@@ -1301,6 +1301,21 @@ const zhHans = {
inaccurateReasons: '不准确原因', inaccurateReasons: '不准确原因',
platform: '平台', platform: '平台',
exportFeedback: '导出反馈', exportFeedback: '导出反馈',
description:
'告诉我们遇到的问题或想要的改进。提交时会附带实例 UUID 和登录账号,方便定位。',
placeholder: '请描述你的建议、问题或复现步骤...',
attachImage: '添加图片',
screenshot: '截图',
submit: '提交反馈',
privacyHint: '请勿提交敏感密钥、密码或私人聊天内容。',
contentRequired: '请先填写反馈内容',
imageOnly: '仅支持图片附件',
imageTooLarge: '单张图片不能超过 1MB',
tooManyImages: '最多添加 3 张图片',
screenshotFailed: '截图失败,请尝试粘贴或上传图片',
submitSuccess: '反馈已提交,感谢!',
submitFailed: '反馈提交失败,请稍后重试',
removeImage: '移除图片',
}, },
queries: { queries: {
title: '查询记录', title: '查询记录',
+15
View File
@@ -1300,6 +1300,21 @@ const zhHant = {
inaccurateReasons: '不準確原因', inaccurateReasons: '不準確原因',
platform: '平台', platform: '平台',
exportFeedback: '匯出反饋', exportFeedback: '匯出反饋',
description:
'告訴我們遇到的問題或想要的改進。提交時會附帶實例 UUID 和登入帳號,方便定位。',
placeholder: '請描述你的建議、問題或重現步驟...',
attachImage: '新增圖片',
screenshot: '截圖',
submit: '提交反饋',
privacyHint: '請勿提交敏感金鑰、密碼或私人聊天內容。',
contentRequired: '請先填寫反饋內容',
imageOnly: '僅支援圖片附件',
imageTooLarge: '單張圖片不能超過 1MB',
tooManyImages: '最多新增 3 張圖片',
screenshotFailed: '截圖失敗,請嘗試貼上或上傳圖片',
submitSuccess: '反饋已提交,感謝!',
submitFailed: '反饋提交失敗,請稍後再試',
removeImage: '移除圖片',
}, },
messageDetails: { messageDetails: {
noData: '此查詢沒有LLM調用或錯誤記錄', noData: '此查詢沒有LLM調用或錯誤記錄',