mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-25 06:54:19 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c22a1521c | |||
| c8d5039580 | |||
| 85d8d9304e | |||
| 76471af179 | |||
| 59b2a7cd51 |
@@ -62,11 +62,12 @@ services:
|
|||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
# Unified env-override convention: SECTION__SUBSECTION__KEY overrides the
|
# Unified env-override convention: SECTION__SUBSECTION__KEY overrides the
|
||||||
# matching config.yaml field (see LoadConfigStage). These map onto
|
# matching config.yaml field (see LoadConfigStage). These map onto
|
||||||
# box.local.* and are forwarded to the Box runtime via INIT RPC.
|
# box.* and are forwarded to the Box runtime via INIT RPC.
|
||||||
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
||||||
- BOX__LOCAL__SKILLS_ROOT=skills
|
- BOX__LOCAL__SKILLS_ROOT=skills
|
||||||
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
|
- BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true}
|
||||||
ports:
|
ports:
|
||||||
- 5300:5300 # For web ui and webhook callback
|
- 5300:5300 # For web ui and webhook callback
|
||||||
- 2280-2285:2280-2285 # For platform reverse connection
|
- 2280-2285:2280-2285 # For platform reverse connection
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ class BoxService:
|
|||||||
return self._enabled
|
return self._enabled
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self._ensure_default_workspace()
|
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
# Disabled by config: do NOT connect to a remote runtime, do NOT
|
# Disabled by config: do NOT connect to a remote runtime, do NOT
|
||||||
# fork a stdio subprocess. Every consumer of box_service should
|
# fork a stdio subprocess. Every consumer of box_service should
|
||||||
@@ -99,6 +98,7 @@ class BoxService:
|
|||||||
await self._runtime_connector.initialize()
|
await self._runtime_connector.initialize()
|
||||||
else:
|
else:
|
||||||
await self.client.initialize()
|
await self.client.initialize()
|
||||||
|
self._ensure_default_workspace()
|
||||||
self._available = True
|
self._available = True
|
||||||
self._connector_error = ''
|
self._connector_error = ''
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
@@ -1152,6 +1152,9 @@ class BoxService:
|
|||||||
if self.default_workspace is None:
|
if self.default_workspace is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not self.shares_filesystem_with_box:
|
||||||
|
return
|
||||||
|
|
||||||
if os.path.isdir(self.default_workspace):
|
if os.path.isdir(self.default_workspace):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1176,7 +1179,7 @@ class BoxService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
host_path = os.path.realpath(spec.host_path)
|
host_path = os.path.realpath(spec.host_path)
|
||||||
if not os.path.isdir(host_path):
|
if self.shares_filesystem_with_box and not os.path.isdir(host_path):
|
||||||
raise BoxValidationError('host_path must point to an existing directory on the host')
|
raise BoxValidationError('host_path must point to an existing directory on the host')
|
||||||
|
|
||||||
if not self.allowed_mount_roots:
|
if not self.allowed_mount_roots:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ box:
|
|||||||
- './data/box'
|
- './data/box'
|
||||||
- '/tmp'
|
- '/tmp'
|
||||||
workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default.
|
workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default.
|
||||||
|
docker:
|
||||||
|
cpu_limit_enabled: true # When false, Docker sandbox containers are started without --cpus. Memory and PID limits still apply.
|
||||||
e2b:
|
e2b:
|
||||||
api_key: '' # Can also be set via E2B_API_KEY env var.
|
api_key: '' # Can also be set via E2B_API_KEY env var.
|
||||||
api_url: '' # Custom API URL for self-hosted deployments.
|
api_url: '' # Custom API URL for self-hosted deployments.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -256,6 +256,31 @@ class TestSharesFilesystemWithBox:
|
|||||||
assert service.shares_filesystem_with_box is False
|
assert service.shares_filesystem_with_box is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_separated_box_runtime_does_not_create_default_workspace_in_langbot(tmp_path):
|
||||||
|
logger = Mock()
|
||||||
|
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||||
|
host_root = tmp_path / 'box'
|
||||||
|
service = BoxService(make_app(logger, host_root=str(host_root)), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||||
|
service._shares_filesystem_with_box_override = False
|
||||||
|
|
||||||
|
service._ensure_default_workspace()
|
||||||
|
|
||||||
|
assert not (host_root / 'default').exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_separated_box_runtime_allows_box_owned_missing_host_path(tmp_path):
|
||||||
|
logger = Mock()
|
||||||
|
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||||
|
host_root = tmp_path / 'box'
|
||||||
|
service = BoxService(make_app(logger, host_root=str(host_root)), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||||
|
service._shares_filesystem_with_box_override = False
|
||||||
|
|
||||||
|
spec = service.build_spec({'cmd': 'echo hi', 'session_id': 'missing-host-path'})
|
||||||
|
|
||||||
|
assert spec.host_path == str(host_root / 'default')
|
||||||
|
assert not (host_root / 'default').exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_box_service_get_sessions_delegates_to_client():
|
async def test_box_service_get_sessions_delegates_to_client():
|
||||||
client = Mock()
|
client = Mock()
|
||||||
@@ -500,6 +525,7 @@ async def test_box_service_creates_default_workspace_on_initialize(tmp_path):
|
|||||||
app = make_app(logger, [str(allowed_root)])
|
app = make_app(logger, [str(allowed_root)])
|
||||||
app.instance_config.data['box']['local']['default_workspace'] = str(default_workspace)
|
app.instance_config.data['box']['local']['default_workspace'] = str(default_workspace)
|
||||||
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
|
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||||
|
service._shares_filesystem_with_box_override = True
|
||||||
|
|
||||||
await service.initialize()
|
await service.initialize()
|
||||||
|
|
||||||
@@ -514,6 +540,7 @@ async def test_box_service_derives_workspace_and_allowed_root_from_host_root(tmp
|
|||||||
shared_root = tmp_path / 'shared-box-root'
|
shared_root = tmp_path / 'shared-box-root'
|
||||||
app = make_app(logger, host_root=str(shared_root))
|
app = make_app(logger, host_root=str(shared_root))
|
||||||
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
|
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||||
|
service._shares_filesystem_with_box_override = True
|
||||||
|
|
||||||
await service.initialize()
|
await service.initialize()
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{!hideBoxRuntime && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusDot state={boxState} />
|
<StatusDot state={boxState} />
|
||||||
<Box className="w-3.5 h-3.5 text-muted-foreground" />
|
<Box className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
|
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -214,6 +219,8 @@ export default function SystemStatusCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!hideBoxRuntime && (
|
||||||
|
<>
|
||||||
<div className="border-t" />
|
<div className="border-t" />
|
||||||
|
|
||||||
{/* Box Runtime */}
|
{/* Box Runtime */}
|
||||||
@@ -320,7 +327,9 @@ export default function SystemStatusCard({
|
|||||||
{session.image}
|
{session.image}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{session.image}</TooltipContent>
|
<TooltipContent>
|
||||||
|
{session.image}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
@@ -347,14 +356,16 @@ export default function SystemStatusCard({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="text-foreground font-mono truncate">
|
<span className="text-foreground font-mono truncate">
|
||||||
{session.host_path} : {session.mount_path}{' '}
|
{session.host_path} :{' '}
|
||||||
|
{session.mount_path}{' '}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
({session.host_path_mode})
|
({session.host_path_mode})
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{session.host_path} : {session.mount_path} (
|
{session.host_path} :{' '}
|
||||||
|
{session.mount_path} (
|
||||||
{session.host_path_mode})
|
{session.host_path_mode})
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -389,6 +400,8 @@ export default function SystemStatusCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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呼び出しやエラーがありません',
|
||||||
|
|||||||
@@ -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: 'Запросы',
|
||||||
|
|||||||
@@ -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: 'คำค้นหา',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '查询记录',
|
||||||
|
|||||||
@@ -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調用或錯誤記錄',
|
||||||
|
|||||||
Reference in New Issue
Block a user