mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fde91b9e5 | |||
| bf13adebf4 | |||
| fcbad78b1d | |||
| 68b8d17781 | |||
| 0e4a937a70 |
@@ -214,7 +214,7 @@
|
||||
"schedule.daily": "Every day at {{time}}",
|
||||
"schedule.editableAfterCreateTooltip": "You can adjust the schedule after creating the task.",
|
||||
"schedule.weekly": "Every {{weekday}} at {{time}}",
|
||||
"section.title": "Try these scheduled tasks",
|
||||
"section.title": "Try these tasks",
|
||||
"seo-weekly-report.description": "Every Monday, ranking movement, emerging keywords, and pages worth refreshing.",
|
||||
"seo-weekly-report.prompt": "Every Monday at 09:00, give me a lightweight SEO weekly: top ranking movers (up/down), 5 emerging keywords worth targeting, and 3 existing pages ripe for a content refresh.",
|
||||
"seo-weekly-report.title": "SEO weekly report",
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
"schedule.daily": "每天 {{time}}",
|
||||
"schedule.editableAfterCreateTooltip": "创建后可调整",
|
||||
"schedule.weekly": "每{{weekday}} {{time}}",
|
||||
"section.title": "试试这些定时任务",
|
||||
"section.title": "试试这些任务",
|
||||
"seo-weekly-report.description": "每周一一份轻量 SEO 报告:排名变化、新关键词机会、值得翻新的页面",
|
||||
"seo-weekly-report.prompt": "每周一早上 9:00 给我一份轻量 SEO 周报:排名升 / 降 TOP 变动、5 个值得关注的新关键词、3 个适合内容翻新的老页面。",
|
||||
"seo-weekly-report.title": "SEO 排名周报",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TaskTemplateRecommendationsUIState } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
|
||||
|
||||
import KanbanBoard from './KanbanBoard';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
recommendationsState: { mode: 'hidden' } as TaskTemplateRecommendationsUIState,
|
||||
taskState: {
|
||||
isTaskGroupListInit: true,
|
||||
taskGroups: [] as Array<{ key: string; tasks: unknown[]; total: number }>,
|
||||
updateTaskStatus: vi.fn(),
|
||||
useFetchTaskGroupList: vi.fn(),
|
||||
},
|
||||
useTaskTemplateRecommendationsUI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@dnd-kit/core', () => {
|
||||
const Wrapper = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
|
||||
|
||||
return {
|
||||
DndContext: Wrapper,
|
||||
DragOverlay: Wrapper,
|
||||
KeyboardSensor: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
pointerWithin: vi.fn(),
|
||||
useSensor: vi.fn((sensor, options) => ({ options, sensor })),
|
||||
useSensors: vi.fn((...sensors) => sensors),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@lobehub/ui', () => {
|
||||
const Div = ({ children, ...props }: any) => <div {...props}>{children}</div>;
|
||||
|
||||
return {
|
||||
Block: Div,
|
||||
Center: Div,
|
||||
Empty: ({ description }: { description: string }) => <div>{description}</div>,
|
||||
Flexbox: Div,
|
||||
Icon: () => <span data-testid="icon" />,
|
||||
Text: Div,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('antd-style', () => ({
|
||||
cssVar: {
|
||||
colorFillSecondary: '#222',
|
||||
colorTextSecondary: '#999',
|
||||
},
|
||||
createStaticStyles: () => ({
|
||||
board: 'board',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: (namespace: string) => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
chat: {
|
||||
'taskList.empty': 'No tasks yet',
|
||||
},
|
||||
taskTemplate: {
|
||||
'section.title': 'Try these tasks',
|
||||
},
|
||||
};
|
||||
|
||||
return translations[namespace]?.[key] ?? key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/RecommendTaskTemplates/TaskTemplateRecommendationsView', () => ({
|
||||
TaskTemplateRecommendationsView: ({
|
||||
state,
|
||||
variant,
|
||||
}: {
|
||||
state: TaskTemplateRecommendationsUIState;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<div data-testid="task-template-recommendations">
|
||||
{state.mode}:{variant}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI', () => ({
|
||||
useTaskTemplateRecommendationsUI: mocks.useTaskTemplateRecommendationsUI,
|
||||
}));
|
||||
|
||||
vi.mock('@/store/global', () => ({
|
||||
useGlobalStore: (
|
||||
selector: (state: {
|
||||
hiddenColumns: string[];
|
||||
hiddenPanelCollapsed: boolean;
|
||||
updateSystemStatus: () => void;
|
||||
}) => unknown,
|
||||
) =>
|
||||
selector({
|
||||
hiddenColumns: [],
|
||||
hiddenPanelCollapsed: false,
|
||||
updateSystemStatus: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/global/selectors', () => ({
|
||||
systemStatusSelectors: {
|
||||
taskKanbanHiddenColumns: (state: { hiddenColumns: string[] }) => state.hiddenColumns,
|
||||
taskKanbanHiddenPanelCollapsed: (state: { hiddenPanelCollapsed: boolean }) =>
|
||||
state.hiddenPanelCollapsed,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/task', () => ({
|
||||
useTaskStore: (selector: (state: typeof mocks.taskState) => unknown) => selector(mocks.taskState),
|
||||
}));
|
||||
|
||||
vi.mock('../CreateTaskModal', () => ({
|
||||
createTaskModal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../features/AgentTaskItem', () => ({
|
||||
default: ({ task }: { task: { identifier: string } }) => <div>{task.identifier}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./HiddenColumnsPanel', () => ({
|
||||
default: () => <div data-testid="hidden-columns-panel" />,
|
||||
}));
|
||||
|
||||
vi.mock('./KanbanColumn', () => ({
|
||||
COLUMN_I18N_KEYS: {
|
||||
backlog: 'taskList.status.backlog',
|
||||
canceled: 'taskList.status.canceled',
|
||||
done: 'taskList.status.done',
|
||||
needsInput: 'taskList.status.needsInput',
|
||||
running: 'taskList.status.running',
|
||||
},
|
||||
COLUMN_STATUS_ICON: {
|
||||
backlog: null,
|
||||
canceled: null,
|
||||
done: null,
|
||||
needsInput: null,
|
||||
running: null,
|
||||
},
|
||||
COLUMN_WIDTH: 280,
|
||||
default: ({ columnKey, loading }: { columnKey: string; loading?: boolean }) => (
|
||||
<div data-testid={`kanban-column-${columnKey}`}>{loading ? 'loading' : columnKey}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('KanbanBoard recommendations empty state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.recommendationsState = { mode: 'hidden' };
|
||||
mocks.taskState = {
|
||||
isTaskGroupListInit: true,
|
||||
taskGroups: [],
|
||||
updateTaskStatus: vi.fn(),
|
||||
useFetchTaskGroupList: vi.fn(),
|
||||
};
|
||||
mocks.useTaskTemplateRecommendationsUI.mockImplementation(() => mocks.recommendationsState);
|
||||
});
|
||||
|
||||
it('renders recommended task templates when the kanban task list is empty', () => {
|
||||
mocks.recommendationsState = {
|
||||
mode: 'cards',
|
||||
onCreated: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
recommendationBatchId: 'batch-1',
|
||||
templates: [],
|
||||
userInterestCount: 0,
|
||||
};
|
||||
|
||||
render(<KanbanBoard />);
|
||||
|
||||
expect(screen.getByText('Try these tasks')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('task-template-recommendations')).toHaveTextContent('cards:compact');
|
||||
expect(mocks.useTaskTemplateRecommendationsUI).toHaveBeenCalledWith({ enabled: true });
|
||||
});
|
||||
|
||||
it('keeps the recommendation request disabled while kanban columns are loading', () => {
|
||||
mocks.taskState = {
|
||||
isTaskGroupListInit: false,
|
||||
taskGroups: [],
|
||||
updateTaskStatus: vi.fn(),
|
||||
useFetchTaskGroupList: vi.fn(),
|
||||
};
|
||||
|
||||
render(<KanbanBoard />);
|
||||
|
||||
expect(mocks.useTaskTemplateRecommendationsUI).toHaveBeenCalledWith({ enabled: false });
|
||||
expect(screen.queryByTestId('task-template-recommendations')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { Center, Empty, Flexbox } from '@lobehub/ui';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { ClipboardCheckIcon } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useTaskTemplateRecommendationsUI } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
@@ -26,6 +26,7 @@ import { createTaskModal } from '../CreateTaskModal';
|
||||
import AgentTaskItem from '../features/AgentTaskItem';
|
||||
import HiddenColumnsPanel from './HiddenColumnsPanel';
|
||||
import KanbanColumn, { COLUMN_I18N_KEYS, COLUMN_STATUS_ICON, COLUMN_WIDTH } from './KanbanColumn';
|
||||
import { RecommendedTaskTemplatesEmptyState } from './RecommendedTaskTemplatesEmptyState';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
board: css`
|
||||
@@ -182,6 +183,11 @@ const KanbanBoard = memo(() => {
|
||||
[hiddenColumnSet, t, taskGroups],
|
||||
);
|
||||
|
||||
const totalTasks = useMemo(() => taskGroups.reduce((sum, g) => sum + g.total, 0), [taskGroups]);
|
||||
const recommendationState = useTaskTemplateRecommendationsUI({
|
||||
enabled: isInit && totalTasks === 0,
|
||||
});
|
||||
|
||||
if (!isInit) {
|
||||
return (
|
||||
<Flexbox horizontal className={styles.board}>
|
||||
@@ -199,14 +205,8 @@ const KanbanBoard = memo(() => {
|
||||
);
|
||||
}
|
||||
|
||||
const totalTasks = taskGroups.reduce((sum, g) => sum + g.total, 0);
|
||||
|
||||
if (totalTasks === 0) {
|
||||
return (
|
||||
<Center height={'80vh'} width={'100%'}>
|
||||
<Empty description={t('taskList.empty')} icon={ClipboardCheckIcon} />
|
||||
</Center>
|
||||
);
|
||||
return <RecommendedTaskTemplatesEmptyState recommendationState={recommendationState} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Empty, Flexbox, Text } from '@lobehub/ui';
|
||||
import { ClipboardCheckIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { TaskTemplateRecommendationsView } from '@/features/RecommendTaskTemplates/TaskTemplateRecommendationsView';
|
||||
import type { TaskTemplateRecommendationsUIState } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
|
||||
|
||||
interface RecommendedTaskTemplatesEmptyStateProps {
|
||||
recommendationState: TaskTemplateRecommendationsUIState;
|
||||
}
|
||||
|
||||
export const RecommendedTaskTemplatesEmptyState = memo<RecommendedTaskTemplatesEmptyStateProps>(
|
||||
({ recommendationState }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { t: tTaskTemplate } = useTranslation('taskTemplate');
|
||||
|
||||
if (recommendationState.mode === 'hidden') {
|
||||
return (
|
||||
<Flexbox align={'center'} paddingBlock={32} style={{ width: '100%' }}>
|
||||
<Empty description={t('taskList.empty')} icon={ClipboardCheckIcon} />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
gap={12}
|
||||
paddingBlock={'16px 24px'}
|
||||
style={{ marginInline: 'auto', maxWidth: 720, width: '100%' }}
|
||||
>
|
||||
<Text fontSize={15} weight={600}>
|
||||
{tTaskTemplate('section.title')}
|
||||
</Text>
|
||||
<TaskTemplateRecommendationsView state={recommendationState} variant={'compact'} />
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RecommendedTaskTemplatesEmptyState.displayName = 'RecommendedTaskTemplatesEmptyState';
|
||||
@@ -0,0 +1,169 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TaskTemplateRecommendationsUIState } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
|
||||
|
||||
import { DEFAULT_TASK_LIST_VIEW_OPTIONS } from './listViewOptions';
|
||||
import TaskList from './TaskList';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
recommendationsState: { mode: 'hidden' } as TaskTemplateRecommendationsUIState,
|
||||
taskState: {
|
||||
isTaskListInit: true,
|
||||
tasks: [] as Array<{ identifier: string; status: string }>,
|
||||
},
|
||||
useTaskTemplateRecommendationsUI: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui', () => {
|
||||
const Div = ({ children, ...props }: any) => <div {...props}>{children}</div>;
|
||||
|
||||
return {
|
||||
Accordion: Div,
|
||||
AccordionItem: ({ children, title }: any) => (
|
||||
<section>
|
||||
<header>{title}</header>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
Block: Div,
|
||||
Center: Div,
|
||||
Empty: ({ description }: { description: string }) => <div>{description}</div>,
|
||||
Flexbox: Div,
|
||||
Icon: () => <span data-testid="icon" />,
|
||||
Skeleton: {
|
||||
Avatar: () => <div data-testid="skeleton-avatar" />,
|
||||
Button: () => <div data-testid="skeleton-button" />,
|
||||
Input: () => <div data-testid="skeleton-input" />,
|
||||
},
|
||||
Text: Div,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Divider: () => <hr />,
|
||||
}));
|
||||
|
||||
vi.mock('antd-style', () => ({
|
||||
cssVar: {
|
||||
colorBorder: '#ddd',
|
||||
colorTextDescription: '#999',
|
||||
orange: '#f60',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: (namespace: string) => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
chat: {
|
||||
'taskList.empty': 'No tasks yet',
|
||||
},
|
||||
taskTemplate: {
|
||||
'section.title': 'Try these tasks',
|
||||
},
|
||||
};
|
||||
|
||||
return translations[namespace]?.[key] ?? key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/RecommendTaskTemplates/TaskTemplateRecommendationsView', () => ({
|
||||
TaskTemplateRecommendationsView: ({
|
||||
state,
|
||||
variant,
|
||||
}: {
|
||||
state: TaskTemplateRecommendationsUIState;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<div data-testid="task-template-recommendations">
|
||||
{state.mode}:{variant}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI', () => ({
|
||||
useTaskTemplateRecommendationsUI: mocks.useTaskTemplateRecommendationsUI,
|
||||
}));
|
||||
|
||||
vi.mock('@/store/task', () => ({
|
||||
useTaskStore: (selector: (state: typeof mocks.taskState) => unknown) => selector(mocks.taskState),
|
||||
}));
|
||||
|
||||
vi.mock('../features/AgentTaskItem', () => ({
|
||||
default: ({ task }: { task: { identifier: string } }) => <div>{task.identifier}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../features/AssigneeAvatar', () => ({
|
||||
default: () => <span data-testid="assignee-avatar" />,
|
||||
}));
|
||||
|
||||
vi.mock('../features/icons/PriorityHighIcon', () => ({
|
||||
default: () => <span data-testid="priority-high-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('../features/icons/PriorityLowIcon', () => ({
|
||||
default: () => <span data-testid="priority-low-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('../features/icons/PriorityMediumIcon', () => ({
|
||||
default: () => <span data-testid="priority-medium-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('../features/icons/PriorityNoneIcon', () => ({
|
||||
default: () => <span data-testid="priority-none-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('../features/icons/PriorityUrgentIcon', () => ({
|
||||
default: () => <span data-testid="priority-urgent-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('../features/TaskStatusIcon', () => ({
|
||||
default: () => <span data-testid="task-status-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('../shared/useAgentDisplayMeta', () => ({
|
||||
useAgentDisplayMeta: () => ({ title: 'Agent' }),
|
||||
}));
|
||||
|
||||
describe('TaskList recommendations empty state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.recommendationsState = { mode: 'hidden' };
|
||||
mocks.taskState = {
|
||||
isTaskListInit: true,
|
||||
tasks: [],
|
||||
};
|
||||
mocks.useTaskTemplateRecommendationsUI.mockImplementation(() => mocks.recommendationsState);
|
||||
});
|
||||
|
||||
it('renders recommended task templates on the empty task list', () => {
|
||||
mocks.recommendationsState = {
|
||||
mode: 'cards',
|
||||
onCreated: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
recommendationBatchId: 'batch-1',
|
||||
templates: [],
|
||||
userInterestCount: 0,
|
||||
};
|
||||
|
||||
render(<TaskList options={DEFAULT_TASK_LIST_VIEW_OPTIONS} />);
|
||||
|
||||
expect(screen.getByText('Try these tasks')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('task-template-recommendations')).toHaveTextContent('cards:compact');
|
||||
expect(mocks.useTaskTemplateRecommendationsUI).toHaveBeenCalledWith({ enabled: true });
|
||||
});
|
||||
|
||||
it('keeps the recommendation request disabled while the task list is still loading', () => {
|
||||
mocks.taskState = {
|
||||
isTaskListInit: false,
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
render(<TaskList options={DEFAULT_TASK_LIST_VIEW_OPTIONS} />);
|
||||
|
||||
expect(mocks.useTaskTemplateRecommendationsUI).toHaveBeenCalledWith({ enabled: false });
|
||||
expect(screen.queryByTestId('task-template-recommendations')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Accordion, AccordionItem, Block, Center, Empty, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { Accordion, AccordionItem, Block, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { Divider } from 'antd';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { ClipboardCheckIcon, UserRound } from 'lucide-react';
|
||||
import { UserRound } from 'lucide-react';
|
||||
import { Fragment, memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useTaskTemplateRecommendationsUI } from '@/features/RecommendTaskTemplates/useTaskTemplateRecommendationsUI';
|
||||
import { useTaskStore } from '@/store/task';
|
||||
import { taskListSelectors } from '@/store/task/selectors';
|
||||
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
HIDDEN_WHEN_COMPLETED_STATUSES,
|
||||
sortGroupEntries,
|
||||
} from './listViewOptions';
|
||||
import { RecommendedTaskTemplatesEmptyState } from './RecommendedTaskTemplatesEmptyState';
|
||||
import TaskItemSkeleton from './TaskItemSkeleton';
|
||||
|
||||
interface TaskListProps {
|
||||
@@ -122,6 +124,9 @@ const TaskList = memo<TaskListProps>(({ onShowHiddenCompleted, options }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const tasks = useTaskStore(taskListSelectors.taskList);
|
||||
const isInit = useTaskStore(taskListSelectors.isTaskListInit);
|
||||
const recommendationState = useTaskTemplateRecommendationsUI({
|
||||
enabled: isInit && tasks.length === 0,
|
||||
});
|
||||
const groupBy = normalizeGroupBy(options.groupBy, 'status');
|
||||
const subGroupBy = normalizeGroupBy(options.subGroupBy, 'none');
|
||||
const effectiveSubGroupBy = groupBy === 'none' ? 'none' : subGroupBy;
|
||||
@@ -207,11 +212,7 @@ const TaskList = memo<TaskListProps>(({ onShowHiddenCompleted, options }) => {
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<Center height={'80vh'} width={'100%'}>
|
||||
<Empty description={t('taskList.empty')} icon={ClipboardCheckIcon} />
|
||||
</Center>
|
||||
);
|
||||
return <RecommendedTaskTemplatesEmptyState recommendationState={recommendationState} />;
|
||||
}
|
||||
|
||||
const hiddenFooter = hiddenCount > 0 && (
|
||||
|
||||
@@ -1,43 +1,2 @@
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { BriefCardSkeleton } from '@/features/DailyBrief/BriefCardSkeleton';
|
||||
|
||||
import { TaskTemplateCard } from './TaskTemplateCard';
|
||||
import type { DailyBriefRecommendationsUIState } from './useDailyBriefRecommendationsUI';
|
||||
|
||||
interface DailyBriefRecommendationsViewProps {
|
||||
state: DailyBriefRecommendationsUIState;
|
||||
}
|
||||
|
||||
export const DailyBriefRecommendationsView = memo<DailyBriefRecommendationsViewProps>(
|
||||
({ state }) => {
|
||||
if (state.mode === 'hidden') return null;
|
||||
if (state.mode === 'skeleton') {
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<BriefCardSkeleton />
|
||||
<BriefCardSkeleton />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
{state.templates.map((tmpl, index) => (
|
||||
<TaskTemplateCard
|
||||
key={tmpl.id}
|
||||
position={index}
|
||||
recommendationBatchId={state.recommendationBatchId}
|
||||
template={tmpl}
|
||||
userInterestCount={state.userInterestCount}
|
||||
onCreated={state.onCreated}
|
||||
onDismiss={state.onDismiss}
|
||||
/>
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DailyBriefRecommendationsView.displayName = 'DailyBriefRecommendationsView';
|
||||
export type { TaskTemplateRecommendationsViewProps as DailyBriefRecommendationsViewProps } from './TaskTemplateRecommendationsView';
|
||||
export { TaskTemplateRecommendationsView as DailyBriefRecommendationsView } from './TaskTemplateRecommendationsView';
|
||||
|
||||
@@ -268,6 +268,25 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('TaskTemplateCard analytics', () => {
|
||||
it('renders the compact variant for dense recommendation surfaces', () => {
|
||||
render(
|
||||
<TaskTemplateCard
|
||||
position={0}
|
||||
recommendationBatchId="batch-1"
|
||||
template={makeTemplate()}
|
||||
userInterestCount={1}
|
||||
variant="compact"
|
||||
onCreated={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Template A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Daily')).toBeInTheDocument();
|
||||
expect(screen.getByText('Template description')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not mark an impression as tracked before analytics is ready', () => {
|
||||
mocks.analyticsEnabled = false;
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -59,10 +59,19 @@ interface TaskTemplateCardProps {
|
||||
recommendationBatchId: string;
|
||||
template: RecommendedTaskTemplate;
|
||||
userInterestCount: number;
|
||||
variant?: 'compact' | 'default';
|
||||
}
|
||||
|
||||
export const TaskTemplateCard = memo<TaskTemplateCardProps>(
|
||||
({ onCreated, onDismiss, position, recommendationBatchId, template, userInterestCount }) => {
|
||||
({
|
||||
onCreated,
|
||||
onDismiss,
|
||||
position,
|
||||
recommendationBatchId,
|
||||
template,
|
||||
userInterestCount,
|
||||
variant = 'default',
|
||||
}) => {
|
||||
const { t } = useTranslation('taskTemplate');
|
||||
const { t: tSetting } = useTranslation('setting');
|
||||
const { analytics } = useAnalytics();
|
||||
@@ -356,6 +365,58 @@ export const TaskTemplateCard = memo<TaskTemplateCardProps>(
|
||||
</button>
|
||||
);
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<Block
|
||||
className={cx(styles.card, styles.compactCard)}
|
||||
gap={10}
|
||||
padding={12}
|
||||
ref={cardRef}
|
||||
style={{ borderRadius: cssVar.borderRadiusLG }}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<Flexbox horizontal align={'flex-start'} gap={10} justify={'space-between'}>
|
||||
<Flexbox gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0 }}>
|
||||
<TemplateBriefIcon icon={IconComp} />
|
||||
<Text ellipsis fontSize={15} weight={500}>
|
||||
{title}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align={'center'} className={styles.meta} gap={4}>
|
||||
<Icon icon={Clock} size={12} />
|
||||
<Text fontSize={12} style={{ color: 'inherit' }}>
|
||||
{scheduleText}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<ActionIcon
|
||||
className={`${styles.dismissBtn} task-template-dismiss`}
|
||||
icon={X}
|
||||
size={'small'}
|
||||
title={t('action.dismiss.tooltip')}
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
</Flexbox>
|
||||
|
||||
{description.trim().length > 0 ? (
|
||||
<Text className={styles.compactDescription} fontSize={13} type={'secondary'}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Flexbox horizontal align={'center'} gap={8} justify={'space-between'} wrap={'wrap'}>
|
||||
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0 }}>
|
||||
{hintNode}
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
{primaryButton}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Block
|
||||
className={cx(briefStyles.card, styles.card)}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Flexbox, Grid } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { BriefCardSkeleton } from '@/features/DailyBrief/BriefCardSkeleton';
|
||||
|
||||
import { TaskTemplateCard } from './TaskTemplateCard';
|
||||
import type { TaskTemplateRecommendationsUIState } from './useTaskTemplateRecommendationsUI';
|
||||
|
||||
export interface TaskTemplateRecommendationsViewProps {
|
||||
state: TaskTemplateRecommendationsUIState;
|
||||
variant?: 'compact' | 'default';
|
||||
}
|
||||
|
||||
export const TaskTemplateRecommendationsView = memo<TaskTemplateRecommendationsViewProps>(
|
||||
({ state, variant = 'default' }) => {
|
||||
if (state.mode === 'hidden') return null;
|
||||
if (state.mode === 'skeleton') {
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<BriefCardSkeleton />
|
||||
<BriefCardSkeleton />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<Grid gap={8} maxItemWidth={340} style={{ width: '100%' }}>
|
||||
{state.templates.map((tmpl, index) => (
|
||||
<TaskTemplateCard
|
||||
key={tmpl.id}
|
||||
position={index}
|
||||
recommendationBatchId={state.recommendationBatchId}
|
||||
template={tmpl}
|
||||
userInterestCount={state.userInterestCount}
|
||||
variant={'compact'}
|
||||
onCreated={state.onCreated}
|
||||
onDismiss={state.onDismiss}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
{state.templates.map((tmpl, index) => (
|
||||
<TaskTemplateCard
|
||||
key={tmpl.id}
|
||||
position={index}
|
||||
recommendationBatchId={state.recommendationBatchId}
|
||||
template={tmpl}
|
||||
userInterestCount={state.userInterestCount}
|
||||
onCreated={state.onCreated}
|
||||
onDismiss={state.onDismiss}
|
||||
/>
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TaskTemplateRecommendationsView.displayName = 'TaskTemplateRecommendationsView';
|
||||
@@ -11,6 +11,22 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
compactCard: css`
|
||||
min-height: 168px;
|
||||
|
||||
.ant-btn {
|
||||
height: 32px;
|
||||
padding-inline: 12px !important;
|
||||
}
|
||||
`,
|
||||
compactDescription: css`
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
|
||||
line-height: 1.5;
|
||||
`,
|
||||
dismissBtn: css`
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -1,145 +1,2 @@
|
||||
import type { RecommendedTaskTemplate, TaskTemplateSkillSource } from '@lobechat/const';
|
||||
import { useAnalytics } from '@lobehub/analytics/react';
|
||||
import { App } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { taskTemplateService } from '@/services/taskTemplate';
|
||||
import { useBriefStore } from '@/store/brief';
|
||||
import { briefListSelectors } from '@/store/brief/selectors';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { authSelectors } from '@/store/user/slices/auth/selectors';
|
||||
|
||||
import { createRecommendationBatchId, getTaskTemplateListServedProperties } from './analytics';
|
||||
import { useResolvedInterestKeys } from './useResolvedInterestKeys';
|
||||
|
||||
export type DailyBriefRecommendationsUIState =
|
||||
| { mode: 'hidden' }
|
||||
| { mode: 'skeleton' }
|
||||
| {
|
||||
mode: 'cards';
|
||||
onCreated: (templateId: string) => void;
|
||||
onDismiss: (templateId: string) => void;
|
||||
recommendationBatchId: string;
|
||||
templates: RecommendedTaskTemplate[];
|
||||
userInterestCount: number;
|
||||
};
|
||||
|
||||
export function useDailyBriefRecommendationsUI(): DailyBriefRecommendationsUIState {
|
||||
const { t } = useTranslation('taskTemplate');
|
||||
const { analytics } = useAnalytics();
|
||||
const { message } = App.useApp();
|
||||
const isLogin = useUserStore(authSelectors.isLogin);
|
||||
const useFetchBriefs = useBriefStore((s) => s.useFetchBriefs);
|
||||
useFetchBriefs(isLogin);
|
||||
|
||||
const isInit = useBriefStore(briefListSelectors.isBriefsInit);
|
||||
|
||||
const interestKeys = useResolvedInterestKeys();
|
||||
const swrKey = interestKeys ? [...interestKeys].sort().join(',') : '';
|
||||
const swrEnabled = isLogin && interestKeys !== null;
|
||||
const batchRef = useRef<
|
||||
| {
|
||||
id: string;
|
||||
served: boolean;
|
||||
swrKey: string;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
swrEnabled ? ['taskTemplate.listDailyRecommend', swrKey] : null,
|
||||
async () => taskTemplateService.listDailyRecommend(interestKeys ?? []),
|
||||
{ revalidateOnFocus: false, revalidateOnReconnect: false },
|
||||
);
|
||||
|
||||
const templates = useMemo(() => data?.data ?? [], [data]);
|
||||
|
||||
if (templates.length > 0 && batchRef.current?.swrKey !== swrKey) {
|
||||
batchRef.current = {
|
||||
id: createRecommendationBatchId(),
|
||||
served: false,
|
||||
swrKey,
|
||||
};
|
||||
}
|
||||
|
||||
const recommendationBatchId = batchRef.current?.id;
|
||||
const userInterestCount = interestKeys?.length ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
const batch = batchRef.current;
|
||||
if (!analytics || !batch || batch.served || templates.length === 0) return;
|
||||
|
||||
void analytics.track({
|
||||
name: 'task_template_list_served',
|
||||
properties: getTaskTemplateListServedProperties({
|
||||
recommendationBatchId: batch.id,
|
||||
templates,
|
||||
userInterestCount,
|
||||
}),
|
||||
});
|
||||
batch.served = true;
|
||||
}, [analytics, templates, userInterestCount]);
|
||||
|
||||
const removeTemplateFromList = useCallback(
|
||||
(templateId: string) => {
|
||||
mutate(
|
||||
(current) =>
|
||||
current
|
||||
? { ...current, data: current.data.filter((tmpl) => tmpl.id !== templateId) }
|
||||
: current,
|
||||
{ revalidate: false },
|
||||
);
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
const handleCreated = useCallback(
|
||||
(templateId: string) => {
|
||||
removeTemplateFromList(templateId);
|
||||
},
|
||||
[removeTemplateFromList],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (templateId: string) => {
|
||||
removeTemplateFromList(templateId);
|
||||
try {
|
||||
await taskTemplateService.dismiss(templateId);
|
||||
} catch (error) {
|
||||
console.error('[taskTemplate:dismiss]', error);
|
||||
message.error(t('action.dismiss.error'));
|
||||
mutate();
|
||||
}
|
||||
},
|
||||
[message, mutate, removeTemplateFromList, t],
|
||||
);
|
||||
|
||||
const requiredSources = useMemo(() => {
|
||||
const sources = new Set<TaskTemplateSkillSource>();
|
||||
for (const tmpl of templates) {
|
||||
for (const s of tmpl.requiresSkills ?? []) sources.add(s.source);
|
||||
for (const s of tmpl.optionalSkills ?? []) sources.add(s.source);
|
||||
}
|
||||
return sources;
|
||||
}, [templates]);
|
||||
const useFetchUserKlavisServers = useToolStore((s) => s.useFetchUserKlavisServers);
|
||||
const useFetchLobehubSkillConnections = useToolStore((s) => s.useFetchLobehubSkillConnections);
|
||||
useFetchUserKlavisServers(requiredSources.has('klavis'));
|
||||
useFetchLobehubSkillConnections(requiredSources.has('lobehub'));
|
||||
|
||||
if (!swrEnabled) return { mode: 'hidden' };
|
||||
if (!isInit || isLoading) return { mode: 'skeleton' };
|
||||
if (templates.length === 0) return { mode: 'hidden' };
|
||||
|
||||
return {
|
||||
mode: 'cards',
|
||||
onCreated: handleCreated,
|
||||
onDismiss: handleDismiss,
|
||||
recommendationBatchId: recommendationBatchId ?? createRecommendationBatchId(),
|
||||
templates,
|
||||
userInterestCount,
|
||||
};
|
||||
}
|
||||
export type { TaskTemplateRecommendationsUIState as DailyBriefRecommendationsUIState } from './useTaskTemplateRecommendationsUI';
|
||||
export { useTaskTemplateRecommendationsUI as useDailyBriefRecommendationsUI } from './useTaskTemplateRecommendationsUI';
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { RecommendedTaskTemplate, TaskTemplateSkillSource } from '@lobechat/const';
|
||||
import { useAnalytics } from '@lobehub/analytics/react';
|
||||
import { App } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { taskTemplateService } from '@/services/taskTemplate';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { authSelectors } from '@/store/user/slices/auth/selectors';
|
||||
|
||||
import { createRecommendationBatchId, getTaskTemplateListServedProperties } from './analytics';
|
||||
import { useResolvedInterestKeys } from './useResolvedInterestKeys';
|
||||
|
||||
export type TaskTemplateRecommendationsUIState =
|
||||
| { mode: 'hidden' }
|
||||
| { mode: 'skeleton' }
|
||||
| {
|
||||
mode: 'cards';
|
||||
onCreated: (templateId: string) => void;
|
||||
onDismiss: (templateId: string) => void;
|
||||
recommendationBatchId: string;
|
||||
templates: RecommendedTaskTemplate[];
|
||||
userInterestCount: number;
|
||||
};
|
||||
|
||||
interface UseTaskTemplateRecommendationsUIOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useTaskTemplateRecommendationsUI({
|
||||
enabled = true,
|
||||
}: UseTaskTemplateRecommendationsUIOptions = {}): TaskTemplateRecommendationsUIState {
|
||||
const { t } = useTranslation('taskTemplate');
|
||||
const { analytics } = useAnalytics();
|
||||
const { message } = App.useApp();
|
||||
const isLogin = useUserStore(authSelectors.isLogin);
|
||||
const interestKeys = useResolvedInterestKeys();
|
||||
const swrKey = interestKeys ? [...interestKeys].sort().join(',') : '';
|
||||
const swrEnabled = enabled && isLogin && interestKeys !== null;
|
||||
const batchRef = useRef<
|
||||
| {
|
||||
id: string;
|
||||
served: boolean;
|
||||
swrKey: string;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
swrEnabled ? ['taskTemplate.listDailyRecommend', swrKey] : null,
|
||||
async () => taskTemplateService.listDailyRecommend(interestKeys ?? []),
|
||||
{ revalidateOnFocus: false, revalidateOnReconnect: false },
|
||||
);
|
||||
|
||||
const templates = useMemo(() => data?.data ?? [], [data]);
|
||||
|
||||
if (templates.length > 0 && batchRef.current?.swrKey !== swrKey) {
|
||||
batchRef.current = {
|
||||
id: createRecommendationBatchId(),
|
||||
served: false,
|
||||
swrKey,
|
||||
};
|
||||
}
|
||||
|
||||
const recommendationBatchId = batchRef.current?.id;
|
||||
const userInterestCount = interestKeys?.length ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
const batch = batchRef.current;
|
||||
if (!analytics || !batch || batch.served || templates.length === 0) return;
|
||||
|
||||
void analytics.track({
|
||||
name: 'task_template_list_served',
|
||||
properties: getTaskTemplateListServedProperties({
|
||||
recommendationBatchId: batch.id,
|
||||
templates,
|
||||
userInterestCount,
|
||||
}),
|
||||
});
|
||||
batch.served = true;
|
||||
}, [analytics, templates, userInterestCount]);
|
||||
|
||||
const removeTemplateFromList = useCallback(
|
||||
(templateId: string) => {
|
||||
mutate(
|
||||
(current) =>
|
||||
current
|
||||
? { ...current, data: current.data.filter((tmpl) => tmpl.id !== templateId) }
|
||||
: current,
|
||||
{ revalidate: false },
|
||||
);
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
const handleCreated = useCallback(
|
||||
(templateId: string) => {
|
||||
removeTemplateFromList(templateId);
|
||||
},
|
||||
[removeTemplateFromList],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (templateId: string) => {
|
||||
removeTemplateFromList(templateId);
|
||||
try {
|
||||
await taskTemplateService.dismiss(templateId);
|
||||
} catch (error) {
|
||||
console.error('[taskTemplate:dismiss]', error);
|
||||
message.error(t('action.dismiss.error'));
|
||||
mutate();
|
||||
}
|
||||
},
|
||||
[message, mutate, removeTemplateFromList, t],
|
||||
);
|
||||
|
||||
const requiredSources = useMemo(() => {
|
||||
const sources = new Set<TaskTemplateSkillSource>();
|
||||
for (const tmpl of templates) {
|
||||
for (const s of tmpl.requiresSkills ?? []) sources.add(s.source);
|
||||
for (const s of tmpl.optionalSkills ?? []) sources.add(s.source);
|
||||
}
|
||||
return sources;
|
||||
}, [templates]);
|
||||
const useFetchUserKlavisServers = useToolStore((s) => s.useFetchUserKlavisServers);
|
||||
const useFetchLobehubSkillConnections = useToolStore((s) => s.useFetchLobehubSkillConnections);
|
||||
useFetchUserKlavisServers(requiredSources.has('klavis'));
|
||||
useFetchLobehubSkillConnections(requiredSources.has('lobehub'));
|
||||
|
||||
if (!swrEnabled) return { mode: 'hidden' };
|
||||
if (isLoading) return { mode: 'skeleton' };
|
||||
if (templates.length === 0) return { mode: 'hidden' };
|
||||
|
||||
return {
|
||||
mode: 'cards',
|
||||
onCreated: handleCreated,
|
||||
onDismiss: handleDismiss,
|
||||
recommendationBatchId: recommendationBatchId ?? createRecommendationBatchId(),
|
||||
templates,
|
||||
userInterestCount,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export default {
|
||||
|
||||
'card.templateTag': 'Template',
|
||||
|
||||
'section.title': 'Try these scheduled tasks',
|
||||
'section.title': 'Try these tasks',
|
||||
|
||||
// ===== content-creation =====
|
||||
'daily-topic-pick.title': 'Daily topic radar',
|
||||
|
||||
@@ -17,13 +17,17 @@ const UTC_DAY_1 = new Date('2026-04-24T10:00:00Z');
|
||||
const UTC_DAY_2 = new Date('2026-04-25T10:00:00Z');
|
||||
|
||||
describe('TaskTemplateService.listDailyRecommend', () => {
|
||||
it('returns RECOMMEND_COUNT items when user has matching interests', async () => {
|
||||
it('returns 10 items when user has matching interests', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
|
||||
|
||||
expect(picked).toHaveLength(RECOMMEND_COUNT);
|
||||
expect(picked.every((p) => p.source === 'matched')).toBe(true);
|
||||
const codingMatches = taskTemplates.filter((t) => t.interests.includes('coding'));
|
||||
expect(RECOMMEND_COUNT).toBe(10);
|
||||
expect(picked).toHaveLength(10);
|
||||
const codingMatches = taskTemplates.filter(
|
||||
(t) => t.interests.includes('coding') && isTemplateSkillSourceEligible(t),
|
||||
);
|
||||
const matched = picked.filter((p) => p.source === 'matched');
|
||||
expect(matched).toHaveLength(Math.min(RECOMMEND_COUNT, codingMatches.length));
|
||||
expect(picked.some((p) => codingMatches.some((m) => m.id === p.id))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -63,13 +67,20 @@ describe('TaskTemplateService.listDailyRecommend', () => {
|
||||
it('falls back to fallback categories when user has no interests', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
const picked = await service.listDailyRecommend([], { now: UTC_DAY_1 });
|
||||
const fallbackCategoryCount = taskTemplates.filter(
|
||||
(t) =>
|
||||
TASK_TEMPLATE_FALLBACK_CATEGORIES.includes(t.category) && isTemplateSkillSourceEligible(t),
|
||||
).length;
|
||||
|
||||
expect(picked).toHaveLength(RECOMMEND_COUNT);
|
||||
for (const p of picked) {
|
||||
for (const p of picked.slice(0, fallbackCategoryCount)) {
|
||||
expect(taskTemplates.some((t) => t.id === p.id)).toBe(true);
|
||||
expect(p.source).toBe('fallback');
|
||||
expect(p.fallbackPool).toBe('preferred_category');
|
||||
}
|
||||
expect(
|
||||
picked.slice(fallbackCategoryCount).every((p) => p.fallbackPool === 'all_candidates'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('marks all-candidate fallback when preferred fallback categories are exhausted', async () => {
|
||||
@@ -98,7 +109,7 @@ describe('TaskTemplateService.listDailyRecommend', () => {
|
||||
|
||||
it('unrecognized interest strings fall back to non-matched pool', async () => {
|
||||
const service = new TaskTemplateService('user-1');
|
||||
// Freeform custom input won't match any template's interests — should still return 3 picks
|
||||
// Freeform custom input won't match any template's interests — should still return 10 picks.
|
||||
const picked = await service.listDailyRecommend(['my special hobby'], { now: UTC_DAY_1 });
|
||||
|
||||
expect(picked).toHaveLength(RECOMMEND_COUNT);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TASK_TEMPLATE_FALLBACK_CATEGORIES, taskTemplates } from '@lobechat/cons
|
||||
import { klavisEnv } from '@/config/klavis';
|
||||
import { appEnv } from '@/envs/app';
|
||||
|
||||
export const RECOMMEND_COUNT = 3;
|
||||
export const RECOMMEND_COUNT = 10;
|
||||
|
||||
export const ENABLED_SKILL_SOURCES: ReadonlySet<TaskTemplateSkillSource> = (() => {
|
||||
const sources = new Set<TaskTemplateSkillSource>();
|
||||
|
||||
Reference in New Issue
Block a user