fix: Move email contact (#12323)

* fix: Move email contact

* style: profile

* fix: urk

* fix: urk

* feat: loading indicator

* fix: build error

* fix: sort

* fix: sort

* fix: sort

* fix: sort

* fix: sort
This commit is contained in:
René Wang 2026-03-04 19:37:31 +08:00 committed by GitHub
parent 3f1473d65f
commit 5d19dbf430
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 397 additions and 444 deletions

View file

@ -68,19 +68,19 @@ body:
- type: textarea
attributes:
label: '🐛 Bug Description'
label: '🐛 What happened?'
description: A clear and concise description of the bug, if the above option is `Other`, please also explain in detail.
validations:
required: true
- type: textarea
attributes:
label: '📷 Recurrence Steps'
description: A clear and concise description of how to recurrence.
label: '📷 How to reproduce it?'
description: A clear and concise description of how to reproduce.
- type: textarea
attributes:
label: '🚦 Expected Behavior'
label: '🚦 What it should be?'
description: A clear and concise description of what you expected to happen.
- type: textarea

View file

@ -166,6 +166,8 @@
"cmdk.search.files": "Files",
"cmdk.search.folder": "Folder",
"cmdk.search.folders": "Folders",
"cmdk.search.knowledgeBase": "Library",
"cmdk.search.knowledgeBases": "Libraries",
"cmdk.search.loading": "Searching...",
"cmdk.search.market": "Community",
"cmdk.search.mcp": "MCP Server",
@ -221,6 +223,7 @@
"exportType.allAgentWithMessage": "Export All Agents and Messages",
"exportType.globalSetting": "Export Global Settings",
"feedback": "Feedback",
"feedback.emailContact": "You can also email us at {{email}}",
"feedback.errors.fileTooLarge": "File exceeds 5MB",
"feedback.errors.submitFailed": "Submit failed. Try again.",
"feedback.errors.teamNotFound": "Configuration error",
@ -423,7 +426,7 @@
"userPanel.community": "Community",
"userPanel.data": "Data Storage",
"userPanel.defaultNickname": "Community User",
"userPanel.discord": "Community Support",
"userPanel.discord": "Discord",
"userPanel.docs": "Documentation",
"userPanel.email": "Email Support",
"userPanel.feedback": "Contact Us",

View file

@ -166,6 +166,8 @@
"cmdk.search.files": "文件",
"cmdk.search.folder": "文件夹",
"cmdk.search.folders": "文件夹",
"cmdk.search.knowledgeBase": "知识库",
"cmdk.search.knowledgeBases": "知识库",
"cmdk.search.loading": "搜索中…",
"cmdk.search.market": "社区",
"cmdk.search.mcp": "MCP 服务器",
@ -221,6 +223,7 @@
"exportType.allAgentWithMessage": "导出所有助理和消息",
"exportType.globalSetting": "导出全局设置",
"feedback": "反馈与建议",
"feedback.emailContact": "您也可以发送邮件至 {{email}}",
"feedback.errors.fileTooLarge": "文件大小超过 5MB 限制",
"feedback.errors.submitFailed": "提交失败,请重试",
"feedback.errors.teamNotFound": "配置错误",
@ -423,7 +426,7 @@
"userPanel.community": "社区版",
"userPanel.data": "数据存储",
"userPanel.defaultNickname": "社区版用户",
"userPanel.discord": "社区支持",
"userPanel.discord": "Discord",
"userPanel.docs": "使用文档",
"userPanel.email": "邮件支持",
"userPanel.feedback": "联系我们",

View file

@ -5,6 +5,7 @@ import {
documents,
files,
knowledgeBaseFiles,
knowledgeBases,
messages,
topics,
userMemories,
@ -22,7 +23,8 @@ export type SearchResultType =
| 'message'
| 'mcp'
| 'plugin'
| 'communityAgent';
| 'communityAgent'
| 'knowledgeBase';
export interface BaseSearchResult {
// 1=exact, 2=prefix, 3=contains
@ -111,6 +113,11 @@ export interface PluginSearchResult extends BaseSearchResult {
type: 'plugin';
}
export interface KnowledgeBaseSearchResult extends BaseSearchResult {
avatar: string | null;
type: 'knowledgeBase';
}
export interface AssistantSearchResult extends BaseSearchResult {
author: string;
avatar?: string | null;
@ -131,7 +138,8 @@ export type SearchResult =
| MemorySearchResult
| MCPSearchResult
| PluginSearchResult
| AssistantSearchResult;
| AssistantSearchResult
| KnowledgeBaseSearchResult;
export interface SearchOptions {
agentId?: string;
@ -192,6 +200,9 @@ export class SearchRepo {
if ((!type || type === 'memory') && limits.memory > 0) {
searchPromises.push(this.searchMemories(trimmedQuery, limits.memory));
}
if ((!type || type === 'knowledgeBase') && limits.knowledgeBase > 0) {
searchPromises.push(this.searchKnowledgeBases(trimmedQuery, limits.knowledgeBase));
}
const results = await Promise.all(searchPromises);
@ -213,6 +224,7 @@ export class SearchRepo {
agent: number;
file: number;
folder: number;
knowledgeBase: number;
memory: number;
message: number;
page: number;
@ -225,6 +237,7 @@ export class SearchRepo {
agent: type === 'agent' ? baseLimit : 0,
file: type === 'file' ? baseLimit : 0,
folder: type === 'folder' ? baseLimit : 0,
knowledgeBase: type === 'knowledgeBase' ? baseLimit : 0,
memory: type === 'memory' ? baseLimit : 0,
message: type === 'message' ? baseLimit : 0,
page: type === 'page' ? baseLimit : 0,
@ -239,6 +252,7 @@ export class SearchRepo {
agent: 3,
file: 3,
folder: 3,
knowledgeBase: 3,
memory: 3,
message: 3,
page: 6,
@ -253,6 +267,7 @@ export class SearchRepo {
agent: 3,
file: 6,
folder: 6,
knowledgeBase: 6,
memory: 3,
message: 3,
page: 3,
@ -267,6 +282,7 @@ export class SearchRepo {
agent: 3,
file: 3,
folder: 3,
knowledgeBase: 3,
memory: 3,
message: 6,
page: 3,
@ -280,6 +296,7 @@ export class SearchRepo {
agent: 3,
file: 3,
folder: 3,
knowledgeBase: 3,
memory: 3,
message: 3,
page: 3,
@ -614,4 +631,40 @@ export class SearchRepo {
updatedAt: row.updatedAt,
}));
}
/**
* Search knowledge bases by name and description
*/
private async searchKnowledgeBases(
query: string,
limit: number,
): Promise<KnowledgeBaseSearchResult[]> {
const searchTerm = `%${query}%`;
const rows = await this.db
.select()
.from(knowledgeBases)
.where(
and(
eq(knowledgeBases.userId, this.userId),
or(
ilike(knowledgeBases.name, searchTerm),
ilike(sql`COALESCE(${knowledgeBases.description}, '')`, searchTerm),
),
),
)
.orderBy(desc(knowledgeBases.updatedAt))
.limit(limit);
return rows.map((row) => ({
avatar: row.avatar,
createdAt: row.createdAt,
description: row.description,
id: row.id,
relevance: this.calculateRelevance(row.name, query),
title: row.name,
type: 'knowledgeBase' as const,
updatedAt: row.updatedAt,
}));
}
}

View file

@ -1,5 +1,6 @@
'use client';
import { BRANDING_EMAIL } from '@lobechat/business-const';
import { Button, Flexbox, Icon, Modal } from '@lobehub/ui';
import { App, Form, Input, Upload } from 'antd';
import { ImagePlus, Send } from 'lucide-react';
@ -126,6 +127,10 @@ const FeedbackModal = memo<FeedbackModalProps>(({ initialValues, onClose, open }
}
onCancel={handleCancel}
>
<p style={{ color: 'var(--colorTextSecondary)', fontSize: 14, marginBottom: 16 }}>
{t('feedback.emailContact', { email: BRANDING_EMAIL.business })}
</p>
<Form form={form} initialValues={initialValues} layout="vertical">
<Form.Item
label={t('feedback.fields.title.label')}

View file

@ -6,6 +6,7 @@ import {
ChevronRight,
FileText,
Folder,
Library,
MessageCircle,
MessageSquare,
Plug,
@ -111,6 +112,10 @@ const SearchResults = memo<SearchResultsProps>(
navigate(`/memory/preferences?preferenceId=${result.id}`);
break;
}
case 'knowledgeBase': {
navigate(`/resource/library/${result.id}`);
break;
}
}
onClose();
};
@ -147,6 +152,9 @@ const SearchResults = memo<SearchResultsProps>(
case 'memory': {
return <Brain size={16} />;
}
case 'knowledgeBase': {
return <Library size={16} />;
}
}
};
@ -182,6 +190,9 @@ const SearchResults = memo<SearchResultsProps>(
case 'memory': {
return t('cmdk.search.memory');
}
case 'knowledgeBase': {
return t('cmdk.search.knowledgeBase');
}
}
};
@ -232,6 +243,7 @@ const SearchResults = memo<SearchResultsProps>(
const memoryResults = results.filter((r) => r.type === 'memory');
const mcpResults = results.filter((r) => r.type === 'mcp');
const pluginResults = results.filter((r) => r.type === 'plugin');
const knowledgeBaseResults = results.filter((r) => r.type === 'knowledgeBase');
const assistantResults = results.filter((r) => r.type === 'communityAgent');
// Don't render anything if no results and not loading
@ -361,6 +373,13 @@ const SearchResults = memo<SearchResultsProps>(
</Command.Group>
)}
{knowledgeBaseResults.length > 0 && (
<Command.Group forceMount>
{knowledgeBaseResults.map((result) => renderResultItem(result))}
{renderSearchMore('knowledgeBase', knowledgeBaseResults.length)}
</Command.Group>
)}
{mcpResults.length > 0 && (
<Command.Group forceMount>
{mcpResults.map((result) => renderResultItem(result))}

View file

@ -114,7 +114,9 @@ const CommandMenuContent = memo<CommandMenuContentProps>(({ isClosing, onClose }
<CommandInput />
<Command.List ref={listRef}>
{!(hasSearch && (isSearching || searchResults.length > 0)) && (
{/* Hide cmdk's Empty when we have search results or are loading them,
since force-mounted items aren't counted by cmdk's internal filter */}
{!(hasSearch && (searchResults.length > 0 || isSearching)) && (
<Command.Empty>{t('cmdk.noResults')}</Command.Empty>
)}

View file

@ -22,6 +22,7 @@ const VALID_TYPES = [
'mcp',
'plugin',
'communityAgent',
'knowledgeBase',
] as const;
export type ValidSearchType = (typeof VALID_TYPES)[number];

View file

@ -177,10 +177,7 @@ export default {
'profile.emailInvalid': 'Please enter a valid email address',
'profile.emailPlaceholder': 'new-email@example.com',
'profile.fullName': 'Fullname',
'profile.fullNameInputHint': 'Please enter your new fullname',
'profile.interests': 'Interests',
'profile.interestsAdd': 'Add',
'profile.interestsPlaceholder': 'Enter an interest',
'profile.password': 'Password',
'profile.resetPasswordError': 'Failed to send password reset link',
'profile.resetPasswordSent': 'Password reset link sent, please check your email',
@ -202,7 +199,6 @@ export default {
'profile.updateUsername': 'Update username',
'profile.username': 'Username',
'profile.usernameDuplicate': 'Username is already taken',
'profile.usernameInputHint': 'Please enter your new username',
'profile.usernamePlaceholder': 'Enter a username with letters, numbers, or underscores',
'profile.usernameRequired': 'Username cannot be empty',
'profile.usernameRule': 'Username can only contain letters, numbers, or underscores',

View file

@ -229,6 +229,10 @@ export default {
'cmdk.search.folders': 'Folders',
'cmdk.search.knowledgeBase': 'Library',
'cmdk.search.knowledgeBases': 'Libraries',
'cmdk.search.loading': 'Searching...',
'cmdk.search.market': 'Community',
@ -290,6 +294,7 @@ export default {
'exportType.allAgentWithMessage': 'Export All Agents and Messages',
'exportType.globalSetting': 'Export Global Settings',
'feedback': 'Feedback',
'feedback.emailContact': 'You can also email us at {{email}}',
'feedback.errors.fileTooLarge': 'File exceeds 5MB',
'feedback.errors.submitFailed': 'Submit failed. Try again.',
'feedback.errors.teamNotFound': 'Configuration error',
@ -499,7 +504,7 @@ export default {
'userPanel.community': 'Community',
'userPanel.data': 'Data Storage',
'userPanel.defaultNickname': 'Community User',
'userPanel.discord': 'Community Support',
'userPanel.discord': 'Discord',
'userPanel.docs': 'Documentation',
'userPanel.email': 'Email Support',
'userPanel.feedback': 'Contact Us',

View file

@ -96,7 +96,7 @@ const CreatorRewardBanner = memo(() => {
{t('home.creatorReward.subtitle')}
</p>
<div style={{ marginBlockStart: 4 }}>
<a href={'#'} rel={'noopener noreferrer'} target={'_blank'}>
<a href={'https://lobehub.com/creator?utm_source=lobehub'} rel={'noopener noreferrer'} target={'_blank'}>
<Button type={'primary'}>{t('home.creatorReward.action')}</Button>
</a>
</div>

View file

@ -1,6 +1,6 @@
'use client';
import { BRANDING_EMAIL, SOCIAL_URL } from '@lobechat/business-const';
import { SOCIAL_URL } from '@lobechat/business-const';
import { useAnalytics } from '@lobehub/analytics/react';
import { type MenuProps } from '@lobehub/ui';
import { ActionIcon, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
@ -12,7 +12,6 @@ import {
FileClockIcon,
FlaskConical,
Github,
Mail,
Rocket,
} from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
@ -22,7 +21,7 @@ import { Link } from 'react-router-dom';
import ChangelogModal from '@/components/ChangelogModal';
import HighlightNotification from '@/components/HighlightNotification';
import LabsModal from '@/components/LabsModal';
import { DOCUMENTS_REFER_URL, GITHUB, mailTo } from '@/const/url';
import { DOCUMENTS_REFER_URL, GITHUB } from '@/const/url';
import ThemeButton from '@/features/User/UserPanel/ThemeButton';
import { useFeedbackModal } from '@/hooks/useFeedbackModal';
import { useGlobalStore } from '@/store/global';
@ -156,15 +155,6 @@ const Footer = memo(() => {
</a>
),
},
{
icon: <Icon icon={Mail} />,
key: 'email',
label: (
<a href={mailTo(BRANDING_EMAIL.support)} rel="noopener noreferrer" target="_blank">
{t('userPanel.email')}
</a>
),
},
{
type: 'divider',
},

View file

@ -1,8 +1,10 @@
'use client';
import { LoadingOutlined } from '@ant-design/icons';
import { Flexbox, Text } from '@lobehub/ui';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { Spin, Upload } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import { PencilIcon } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -15,6 +17,36 @@ import { createUploadImageHandler } from '@/utils/uploadFIle';
import { labelStyle, rowStyle } from './ProfileRow';
const styles = createStaticStyles(({ css }) => ({
overlay: css`
cursor: pointer;
position: absolute;
z-index: 1;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
background: ${cssVar.colorBgMask};
border-radius: 8px;
transition: opacity ${cssVar.motionDurationMid} ease;
`,
wrapper: css`
cursor: pointer;
position: relative;
overflow: hidden;
border-radius: 8px;
&:hover .avatar-edit-overlay {
opacity: 1;
}
`,
}));
interface AvatarRowProps {
mobile?: boolean;
}
@ -56,42 +88,33 @@ const AvatarRow = ({ mobile }: AvatarRowProps) => {
const canUpload = isLogin;
const avatarContent = canUpload ? (
<Spin indicator={<LoadingOutlined spin />} spinning={uploading}>
<Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
<UserAvatar clickable size={40} />
</Upload>
</Spin>
<Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
<Spin indicator={<LoadingOutlined spin />} spinning={uploading}>
<div className={styles.wrapper}>
<UserAvatar size={40} />
<div className={`${styles.overlay} avatar-edit-overlay`}>
<Icon color={cssVar.colorTextLightSolid} icon={PencilIcon} size={16} />
</div>
</div>
</Spin>
</Upload>
) : (
<UserAvatar size={40} />
);
const updateAction = canUpload ? (
<Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
<Text fontSize={13} style={{ cursor: 'pointer' }}>
{t('profile.updateAvatar')}
</Text>
</Upload>
) : null;
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Flexbox horizontal align="center" justify="space-between">
<Text strong>{t('profile.avatar')}</Text>
{updateAction}
</Flexbox>
<Flexbox>{avatarContent}</Flexbox>
<Flexbox horizontal align="center" gap={12} justify="space-between" style={rowStyle}>
<Text strong>{t('profile.avatar')}</Text>
{avatarContent}
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={24} justify="space-between" style={rowStyle}>
<Flexbox horizontal align="center" gap={24} style={{ flex: 1 }}>
<Text style={labelStyle}>{t('profile.avatar')}</Text>
<Flexbox style={{ flex: 1 }}>{avatarContent}</Flexbox>
</Flexbox>
{updateAction}
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.avatar')}</Text>
<Flexbox align="flex-end" style={{ flex: 1 }}>{avatarContent}</Flexbox>
</Flexbox>
);
};

View file

@ -1,15 +1,16 @@
'use client';
import { Button, Flexbox, Input, Text } from '@lobehub/ui';
import { AnimatePresence, m as motion } from 'motion/react';
import { useCallback, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Flexbox, Input, Text } from '@lobehub/ui';
import { type InputRef, Spin } from 'antd';
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { fetchErrorNotification } from '@/components/Error/fetchErrorNotification';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { labelStyle, rowStyle } from './ProfileRow';
import { INPUT_WIDTH, labelStyle, rowStyle } from './ProfileRow';
interface FullNameRowProps {
mobile?: boolean;
@ -19,27 +20,16 @@ const FullNameRow = ({ mobile }: FullNameRowProps) => {
const { t } = useTranslation('auth');
const fullName = useUserStore(userProfileSelectors.fullName);
const updateFullName = useUserStore((s) => s.updateFullName);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [saving, setSaving] = useState(false);
const handleStartEdit = () => {
setEditValue(fullName || '');
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
setEditValue('');
};
const inputRef = useRef<InputRef>(null);
const handleSave = useCallback(async () => {
if (!editValue.trim()) return;
const value = inputRef.current?.input?.value?.trim();
if (!value || value === fullName) return;
try {
setSaving(true);
await updateFullName(editValue.trim());
setIsEditing(false);
await updateFullName(value);
} catch (error) {
console.error('Failed to update fullName:', error);
fetchErrorNotification.error({
@ -49,81 +39,39 @@ const FullNameRow = ({ mobile }: FullNameRowProps) => {
} finally {
setSaving(false);
}
}, [editValue, updateFullName]);
}, [fullName, updateFullName]);
const editingContent = (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key="editing"
transition={{ duration: 0.2 }}
>
<Flexbox gap={12}>
{!mobile && <Text strong>{t('profile.fullNameInputHint')}</Text>}
<Input
autoFocus
showCount
maxLength={64}
placeholder={t('profile.fullName')}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onPressEnter={handleSave}
/>
<Flexbox horizontal gap={8} justify="flex-end">
<Button disabled={saving} size="small" onClick={handleCancel}>
{t('profile.cancel')}
</Button>
<Button loading={saving} size="small" type="primary" onClick={handleSave}>
{t('profile.save')}
</Button>
</Flexbox>
</Flexbox>
</motion.div>
);
const displayContent = (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="display"
transition={{ duration: 0.2 }}
>
{mobile ? (
<Text>{fullName || '--'}</Text>
) : (
<Flexbox horizontal align="center" justify="space-between">
<Text>{fullName || '--'}</Text>
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
{t('profile.updateFullName')}
</Text>
</Flexbox>
)}
</motion.div>
const input = (
<Flexbox horizontal align="center" gap={8}>
{saving && <Spin indicator={<LoadingOutlined spin />} size="small" />}
<Input
defaultValue={fullName || ''}
disabled={saving}
key={fullName}
placeholder={t('profile.fullName')}
ref={inputRef}
style={mobile ? undefined : { textAlign: 'right', width: INPUT_WIDTH }}
variant="filled"
onBlur={handleSave}
onPressEnter={handleSave}
/>
</Flexbox>
);
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Flexbox horizontal align="center" justify="space-between">
<Text strong>{t('profile.fullName')}</Text>
{!isEditing && (
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
{t('profile.updateFullName')}
</Text>
)}
</Flexbox>
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
<Text strong>{t('profile.fullName')}</Text>
{input}
</Flexbox>
);
}
return (
<Flexbox horizontal gap={24} style={rowStyle}>
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.fullName')}</Text>
<Flexbox style={{ flex: 1 }}>
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
<Flexbox align="flex-end" style={{ flex: 1 }}>
{input}
</Flexbox>
</Flexbox>
);

View file

@ -1,9 +1,8 @@
'use client';
import { Block, Button, Flexbox, Icon, Input, Tag, Text } from '@lobehub/ui';
import { Block, Flexbox, Icon, Input, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { BriefcaseIcon } from 'lucide-react';
import { AnimatePresence, m as motion } from 'motion/react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -23,8 +22,6 @@ const InterestsRow = ({ mobile }: InterestsRowProps) => {
const { t: tOnboarding } = useTranslation('onboarding');
const interests = useUserStore(userProfileSelectors.interests);
const updateInterests = useUserStore((s) => s.updateInterests);
const [isEditing, setIsEditing] = useState(false);
const [selectedInterests, setSelectedInterests] = useState<string[]>([]);
const [customInput, setCustomInput] = useState('');
const [showCustomInput, setShowCustomInput] = useState(false);
const [saving, setSaving] = useState(false);
@ -38,50 +35,38 @@ const InterestsRow = ({ mobile }: InterestsRowProps) => {
[tOnboarding],
);
const handleStartEdit = () => {
setSelectedInterests([...interests]);
setIsEditing(true);
};
const toggleInterest = useCallback(
async (label: string) => {
const updated = interests.includes(label)
? interests.filter((i) => i !== label)
: [...interests, label];
const handleCancel = () => {
setIsEditing(false);
setSelectedInterests([]);
setCustomInput('');
setShowCustomInput(false);
};
try {
setSaving(true);
await updateInterests(updated);
} catch (error) {
console.error('Failed to update interests:', error);
fetchErrorNotification.error({
errorMessage: error instanceof Error ? error.message : String(error),
status: 500,
});
} finally {
setSaving(false);
}
},
[interests, updateInterests],
);
const toggleInterest = useCallback((label: string) => {
setSelectedInterests((prev) =>
prev.includes(label) ? prev.filter((i) => i !== label) : [...prev, label],
);
}, []);
const handleAddCustom = useCallback(() => {
const handleAddCustom = useCallback(async () => {
const trimmed = customInput.trim();
if (trimmed && !selectedInterests.includes(trimmed)) {
setSelectedInterests((prev) => [...prev, trimmed]);
setCustomInput('');
}
}, [customInput, selectedInterests]);
if (!trimmed || interests.includes(trimmed)) return;
const handleSave = useCallback(async () => {
// Include custom input if has content
const finalInterests = [...selectedInterests];
const trimmedCustom = customInput.trim();
if (showCustomInput && trimmedCustom && !finalInterests.includes(trimmedCustom)) {
finalInterests.push(trimmedCustom);
}
// Deduplicate
const uniqueInterests = [...new Set(finalInterests)];
const updated = [...interests, trimmed];
setCustomInput('');
try {
setSaving(true);
await updateInterests(uniqueInterests);
setIsEditing(false);
setSelectedInterests([]);
setCustomInput('');
setShowCustomInput(false);
await updateInterests(updated);
} catch (error) {
console.error('Failed to update interests:', error);
fetchErrorNotification.error({
@ -91,155 +76,97 @@ const InterestsRow = ({ mobile }: InterestsRowProps) => {
} finally {
setSaving(false);
}
}, [selectedInterests, customInput, showCustomInput, updateInterests]);
}, [customInput, interests, updateInterests]);
const editingContent = (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key="editing"
transition={{ duration: 0.2 }}
>
<Flexbox gap={12}>
<Flexbox horizontal align="center" gap={8} wrap="wrap">
{areas.map((item) => {
const isSelected = selectedInterests.includes(item.label);
return (
<Block
clickable
horizontal
gap={8}
key={item.key}
padding={8}
variant="outlined"
style={
isSelected
? {
background: cssVar.colorFillSecondary,
borderColor: cssVar.colorFillSecondary,
}
: {}
}
onClick={() => toggleInterest(item.label)}
>
<Icon color={cssVar.colorTextSecondary} icon={item.icon} size={14} />
<Text fontSize={13} weight={500}>
{item.label}
</Text>
</Block>
);
})}
{/* Render custom interests with same Block style but no icon */}
{selectedInterests
.filter((i) => !areas.some((a) => a.label === i))
.map((interest) => (
<Block
clickable
key={interest}
padding={8}
variant="outlined"
style={{
background: cssVar.colorFillSecondary,
borderColor: cssVar.colorFillSecondary,
}}
onClick={() => toggleInterest(interest)}
>
<Text fontSize={13} weight={500}>
{interest}
</Text>
</Block>
))}
<Block
clickable
horizontal
gap={8}
padding={8}
variant="outlined"
style={
showCustomInput
? { background: cssVar.colorFillSecondary, borderColor: cssVar.colorFillSecondary }
: {}
}
onClick={() => setShowCustomInput(!showCustomInput)}
>
<Icon color={cssVar.colorTextSecondary} icon={BriefcaseIcon} size={14} />
<Text fontSize={13} weight={500}>
{tOnboarding('interests.area.other')}
</Text>
</Block>
</Flexbox>
{showCustomInput && (
<Input
placeholder={tOnboarding('interests.placeholder')}
size="small"
style={{ width: 200 }}
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onPressEnter={handleAddCustom}
/>
)}
<Flexbox horizontal gap={8} justify="flex-end">
<Button disabled={saving} size="small" onClick={handleCancel}>
{t('profile.cancel')}
</Button>
<Button loading={saving} size="small" type="primary" onClick={handleSave}>
{t('profile.save')}
</Button>
</Flexbox>
</Flexbox>
</motion.div>
);
const displayContent = (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="display"
transition={{ duration: 0.2 }}
>
{mobile ? (
interests.length > 0 ? (
<Flexbox horizontal gap={8} style={{ flexWrap: 'wrap' }}>
{interests.map((interest) => (
<Tag key={interest}>{interest}</Tag>
))}
</Flexbox>
) : (
<Text>--</Text>
)
) : (
<Flexbox horizontal align="center" justify="space-between">
{interests.length > 0 ? (
<Flexbox horizontal gap={8} style={{ flexWrap: 'wrap' }}>
{interests.map((interest) => (
<Tag key={interest}>{interest}</Tag>
))}
</Flexbox>
) : (
<Text>--</Text>
)}
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
{t('profile.updateInterests')}
const content = (
<Flexbox gap={12}>
<Flexbox horizontal align="center" gap={8} justify="flex-end" wrap="wrap">
{areas.map((item) => {
const isSelected = interests.includes(item.label);
return (
<Block
clickable
horizontal
gap={8}
key={item.key}
padding={8}
variant="outlined"
style={
isSelected
? {
background: cssVar.colorFillSecondary,
borderColor: cssVar.colorFillSecondary,
opacity: saving ? 0.6 : 1,
}
: { opacity: saving ? 0.6 : 1 }
}
onClick={() => !saving && toggleInterest(item.label)}
>
<Icon color={cssVar.colorTextSecondary} icon={item.icon} size={14} />
<Text fontSize={13} weight={500}>
{item.label}
</Text>
</Block>
);
})}
{/* Render custom interests */}
{interests
.filter((i) => !areas.some((a) => a.label === i))
.map((interest) => (
<Block
clickable
key={interest}
padding={8}
variant="outlined"
style={{
background: cssVar.colorFillSecondary,
borderColor: cssVar.colorFillSecondary,
opacity: saving ? 0.6 : 1,
}}
onClick={() => !saving && toggleInterest(interest)}
>
<Text fontSize={13} weight={500}>
{interest}
</Text>
</Block>
))}
<Block
clickable
horizontal
gap={8}
padding={8}
variant="outlined"
style={
showCustomInput
? { background: cssVar.colorFillSecondary, borderColor: cssVar.colorFillSecondary }
: {}
}
onClick={() => setShowCustomInput(!showCustomInput)}
>
<Icon color={cssVar.colorTextSecondary} icon={BriefcaseIcon} size={14} />
<Text fontSize={13} weight={500}>
{tOnboarding('interests.area.other')}
</Text>
</Flexbox>
</Block>
</Flexbox>
{showCustomInput && (
<Input
placeholder={tOnboarding('interests.placeholder')}
size="small"
style={{ width: 200 }}
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onPressEnter={handleAddCustom}
/>
)}
</motion.div>
</Flexbox>
);
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Flexbox horizontal align="center" justify="space-between">
<Text strong>{t('profile.interests')}</Text>
{!isEditing && (
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
{t('profile.updateInterests')}
</Text>
)}
</Flexbox>
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
<Text strong>{t('profile.interests')}</Text>
{content}
</Flexbox>
);
}
@ -247,9 +174,7 @@ const InterestsRow = ({ mobile }: InterestsRowProps) => {
return (
<Flexbox horizontal gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.interests')}</Text>
<Flexbox style={{ flex: 1 }}>
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
</Flexbox>
<Flexbox align="flex-end" style={{ flex: 1 }}>{content}</Flexbox>
</Flexbox>
);
};

View file

@ -1,6 +1,6 @@
'use client';
import { Text } from '@lobehub/ui';
import { Button, Flexbox, Text } from '@lobehub/ui';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -8,7 +8,7 @@ import { notification } from '@/components/AntdStaticMethods';
import { useUserStore } from '@/store/user';
import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
import ProfileRow from './ProfileRow';
import { labelStyle, rowStyle } from './ProfileRow';
interface PasswordRowProps {
mobile?: boolean;
@ -43,25 +43,26 @@ const PasswordRow = ({ mobile }: PasswordRowProps) => {
}
}, [userProfile?.email, t]);
return (
<ProfileRow
label={t('profile.password')}
mobile={mobile}
action={
<Text
style={{
cursor: sending ? 'default' : 'pointer',
fontSize: 13,
opacity: sending ? 0.5 : 1,
}}
onClick={sending ? undefined : handleChangePassword}
>
if (mobile) {
return (
<Flexbox horizontal align="center" gap={12} justify="space-between" style={rowStyle}>
<Text strong>{t('profile.password')}</Text>
<Button loading={sending} size="small" onClick={handleChangePassword}>
{hasPasswordAccount ? t('profile.changePassword') : t('profile.setPassword')}
</Text>
}
>
<Text>{hasPasswordAccount ? '••••••' : '--'}</Text>
</ProfileRow>
</Button>
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.password')}</Text>
<Flexbox align="flex-end" style={{ flex: 1 }}>
<Button loading={sending} size="small" onClick={handleChangePassword}>
{hasPasswordAccount ? t('profile.changePassword') : t('profile.setPassword')}
</Button>
</Flexbox>
</Flexbox>
);
};

View file

@ -20,6 +20,8 @@ export const labelStyle: CSSProperties = {
width: 160,
};
export const INPUT_WIDTH = 240;
const ProfileRow = ({ label, children, action, mobile }: ProfileRowProps) => {
if (mobile) {
return (
@ -34,11 +36,9 @@ const ProfileRow = ({ label, children, action, mobile }: ProfileRowProps) => {
}
return (
<Flexbox horizontal align="center" gap={24} justify="space-between" style={rowStyle}>
<Flexbox horizontal align="center" gap={24} style={{ flex: 1 }}>
<Text style={labelStyle}>{label}</Text>
<Flexbox style={{ flex: 1 }}>{children}</Flexbox>
</Flexbox>
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{label}</Text>
<Flexbox align="flex-end" style={{ flex: 1 }}>{children}</Flexbox>
{action && <Flexbox>{action}</Flexbox>}
</Flexbox>
);

View file

@ -1,15 +1,16 @@
'use client';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Flexbox, Input, Text } from '@lobehub/ui';
import { AnimatePresence, m as motion } from 'motion/react';
import { type InputRef, Spin } from 'antd';
import { type ChangeEvent } from 'react';
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { labelStyle, rowStyle } from './ProfileRow';
import { INPUT_WIDTH, labelStyle, rowStyle } from './ProfileRow';
interface UsernameRowProps {
mobile?: boolean;
@ -19,25 +20,13 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
const { t } = useTranslation('auth');
const username = useUserStore(userProfileSelectors.username);
const updateUsername = useUserStore((s) => s.updateUsername);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [dirty, setDirty] = useState(false);
const inputRef = useRef<InputRef>(null);
const usernameRegex = /^\w+$/;
const handleStartEdit = () => {
setEditValue(username || '');
setError('');
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
setEditValue('');
setError('');
};
const validateUsername = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return t('profile.usernameRequired');
@ -47,7 +36,13 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
};
const handleSave = useCallback(async () => {
const validationError = validateUsername(editValue);
const value = inputRef.current?.input?.value?.trim();
if (!value || value === username) {
setError('');
return;
}
const validationError = validateUsername(value);
if (validationError) {
setError(validationError);
return;
@ -56,11 +51,10 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
try {
setSaving(true);
setError('');
await updateUsername(editValue.trim());
setIsEditing(false);
await updateUsername(value);
setDirty(false);
} catch (err: any) {
console.error('Failed to update username:', err);
// Handle duplicate username error
if (err?.data?.code === 'CONFLICT' || err?.message === 'USERNAME_TAKEN') {
setError(t('profile.usernameDuplicate'));
} else {
@ -69,104 +63,94 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
} finally {
setSaving(false);
}
}, [editValue, updateUsername, t]);
}, [username, updateUsername, t]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEditValue(value);
setDirty(value.trim() !== (username || ''));
if (!value.trim()) {
setError('');
return;
}
if (!usernameRegex.test(value)) {
setError(t('profile.usernameRule'));
return;
}
setError('');
};
const editingContent = (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key="editing"
transition={{ duration: 0.2 }}
>
<Flexbox gap={12}>
{!mobile && <Text strong>{t('profile.usernameInputHint')}</Text>}
<Input
autoFocus
showCount
maxLength={64}
placeholder={t('profile.usernamePlaceholder')}
status={error ? 'error' : undefined}
value={editValue}
onChange={handleInputChange}
onPressEnter={handleSave}
/>
const handleCancel = useCallback(() => {
if (inputRef.current?.input) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
nativeInputValueSetter?.call(inputRef.current.input, username || '');
inputRef.current.input.dispatchEvent(new Event('input', { bubbles: true }));
}
setError('');
setDirty(false);
inputRef.current?.blur();
}, [username]);
const input = (
<Flexbox gap={4}>
<Flexbox horizontal align="center" gap={8}>
{saving && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
<Text style={{ fontSize: 12 }} type="danger">
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }} type="danger">
{error}
</Text>
)}
<Flexbox horizontal gap={8} justify="flex-end">
<Button disabled={saving} size="small" onClick={handleCancel}>
{dirty && !saving && (
<Button
onMouseDown={(e) => {
e.preventDefault();
handleCancel();
}}
size="small"
variant="outlined"
>
{t('profile.cancel')}
</Button>
<Button loading={saving} size="small" type="primary" onClick={handleSave}>
{t('profile.save')}
</Button>
</Flexbox>
)}
<Input
defaultValue={username || ''}
disabled={saving}
key={username}
placeholder={t('profile.usernamePlaceholder')}
ref={inputRef}
status={error ? 'error' : undefined}
style={mobile ? undefined : { textAlign: 'right', width: INPUT_WIDTH }}
variant="filled"
onBlur={handleSave}
onChange={handleChange}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
}}
onPressEnter={handleSave}
/>
</Flexbox>
</motion.div>
);
const displayContent = (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="display"
transition={{ duration: 0.2 }}
>
{mobile ? (
<Text>{username || '--'}</Text>
) : (
<Flexbox horizontal align="center" justify="space-between">
<Text>{username || '--'}</Text>
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
{t('profile.updateUsername')}
</Text>
</Flexbox>
)}
</motion.div>
</Flexbox>
);
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Flexbox horizontal align="center" justify="space-between">
<Text strong>{t('profile.username')}</Text>
{!isEditing && (
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
{t('profile.updateUsername')}
</Text>
)}
</Flexbox>
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
<Text strong>{t('profile.username')}</Text>
{input}
</Flexbox>
);
}
return (
<Flexbox horizontal gap={24} style={rowStyle}>
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.username')}</Text>
<Flexbox style={{ flex: 1 }}>
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
<Flexbox align="flex-end" style={{ flex: 1 }}>
{input}
</Flexbox>
</Flexbox>
);

View file

@ -28,21 +28,15 @@ const SkeletonRow = ({ mobile }: { mobile?: boolean }) => {
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Flexbox horizontal align="center" justify="space-between">
<Skeleton.Button active size="small" style={{ height: 22, width: 60 }} />
<Skeleton.Button active size="small" style={{ height: 22, width: 80 }} />
</Flexbox>
<Skeleton.Button active size="small" style={{ height: 22, width: 60 }} />
<Skeleton.Button active size="small" style={{ height: 22, width: 120 }} />
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={24} justify="space-between" style={rowStyle}>
<Flexbox horizontal align="center" gap={24} style={{ flex: 1 }}>
<Skeleton.Button active size="small" style={{ ...labelStyle, height: 22 }} />
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 120, width: 160 }} />
</Flexbox>
<Skeleton.Button active size="small" style={{ height: 22, width: 100 }} />
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Skeleton.Button active size="small" style={{ ...labelStyle, height: 22 }} />
<Skeleton.Button active size="small" style={{ height: 22, marginInlineStart: 'auto', width: 120 }} />
</Flexbox>
);
};

View file

@ -56,6 +56,7 @@ export const searchRouter = router({
'mcp',
'plugin',
'communityAgent',
'knowledgeBase',
])
.optional(),
}),
@ -70,7 +71,7 @@ export const searchRouter = router({
const searchPromises: Promise<any>[] = [];
// Database searches (agent, topic, file, folder, message, page, memory)
if (!type || ['agent', 'topic', 'file', 'folder', 'message', 'page', 'memory'].includes(type)) {
if (!type || ['agent', 'topic', 'file', 'folder', 'message', 'page', 'memory', 'knowledgeBase'].includes(type)) {
searchPromises.push(ctx.searchRepo.search(input));
}