diff --git a/locales/en-US/topic.json b/locales/en-US/topic.json index 7a46e17c4e..45dc0390a4 100644 --- a/locales/en-US/topic.json +++ b/locales/en-US/topic.json @@ -15,20 +15,24 @@ "actions.export": "Export Topics", "actions.favorite": "Favorite", "actions.import": "Import Conversation", + "actions.markCompleted": "Mark as Completed", "actions.openInNewTab": "Open in New Tab", "actions.openInNewWindow": "Open in a new window", "actions.removeAll": "Delete All Topics", "actions.removeUnstarred": "Delete Unstarred Topics", "actions.unfavorite": "Unfavorite", + "actions.unmarkCompleted": "Mark as Active", "defaultTitle": "Default Topic", "displayItems": "Display Items", "duplicateLoading": "Copying Topic...", "duplicateSuccess": "Topic Copied Successfully", "favorite": "Favorite", + "filter.filter": "Filter", "filter.groupMode.byProject": "By project", "filter.groupMode.byTime": "By time", "filter.groupMode.flat": "Flat", "filter.organize": "Organize", + "filter.showCompleted": "Include Completed", "filter.sort": "Sort by", "filter.sortBy.createdAt": "Created time", "filter.sortBy.updatedAt": "Updated time", diff --git a/locales/zh-CN/topic.json b/locales/zh-CN/topic.json index 569c3590e3..6035e0fd48 100644 --- a/locales/zh-CN/topic.json +++ b/locales/zh-CN/topic.json @@ -15,20 +15,24 @@ "actions.export": "导出话题", "actions.favorite": "收藏", "actions.import": "导入对话", + "actions.markCompleted": "标为已完成", "actions.openInNewTab": "在新标签页中打开", "actions.openInNewWindow": "打开独立窗口", "actions.removeAll": "删除全部话题", "actions.removeUnstarred": "删除未收藏话题", "actions.unfavorite": "取消收藏", + "actions.unmarkCompleted": "标为进行中", "defaultTitle": "默认话题", "displayItems": "显示条目", "duplicateLoading": "话题复制中…", "duplicateSuccess": "话题复制成功", "favorite": "收藏", + "filter.filter": "筛选", "filter.groupMode.byProject": "按项目", "filter.groupMode.byTime": "按时间阶段", "filter.groupMode.flat": "平铺", "filter.organize": "整理", + "filter.showCompleted": "显示已完成", "filter.sort": "排序", "filter.sortBy.createdAt": "按创建时间", "filter.sortBy.updatedAt": "按更新时间", diff --git a/packages/const/src/user.ts b/packages/const/src/user.ts index c77a259142..7f87efe613 100644 --- a/packages/const/src/user.ts +++ b/packages/const/src/user.ts @@ -17,6 +17,7 @@ export const DEFAULT_PREFERENCE: UserPreference = { enableInputMarkdown: true, }, topicGroupMode: 'byTime', + topicIncludeCompleted: false, topicSortBy: 'updatedAt', useCmdEnterToSend: false, }; diff --git a/packages/database/src/models/__tests__/topics/topic.query.test.ts b/packages/database/src/models/__tests__/topics/topic.query.test.ts index 06161ec115..d7274763df 100644 --- a/packages/database/src/models/__tests__/topics/topic.query.test.ts +++ b/packages/database/src/models/__tests__/topics/topic.query.test.ts @@ -207,6 +207,134 @@ describe('TopicModel - Query', () => { expect(ids).toContain('null-trigger'); expect(ids).not.toContain('cron-topic'); }); + + it('should only return topics with matching triggers when triggers is set', async () => { + await serverDB.insert(topics).values([ + { id: 'normal-topic', sessionId, userId, title: 'Normal' }, + { id: 'cron-topic', sessionId, userId, title: 'Cron', trigger: 'cron' }, + { id: 'eval-topic', sessionId, userId, title: 'Eval', trigger: 'eval' }, + ]); + + const result = await topicModel.query({ + containerId: sessionId, + triggers: ['cron'], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe('cron-topic'); + }); + + it('should exclude topics with matching status via excludeStatuses, keeping null status', async () => { + const completedAt = new Date('2024-01-05'); + await serverDB.insert(topics).values([ + { id: 'active-topic', sessionId, userId, title: 'Active', status: 'active' }, + { + id: 'completed-topic', + sessionId, + userId, + title: 'Completed', + status: 'completed', + completedAt, + }, + { id: 'archived-topic', sessionId, userId, title: 'Archived', status: 'archived' }, + { id: 'null-status-topic', sessionId, userId, title: 'No status' }, + ]); + + const result = await topicModel.query({ + containerId: sessionId, + excludeStatuses: ['completed'], + }); + + const ids = result.items.map((t) => t.id); + expect(ids).toHaveLength(3); + expect(ids).toContain('active-topic'); + expect(ids).toContain('archived-topic'); + expect(ids).toContain('null-status-topic'); + expect(ids).not.toContain('completed-topic'); + }); + + it('should select status and completedAt on returned topics', async () => { + const completedAt = new Date('2024-02-01T10:00:00Z'); + await serverDB.insert(topics).values([ + { + id: 'with-status', + sessionId, + userId, + title: 'With Status', + status: 'completed', + completedAt, + }, + ]); + + const result = await topicModel.query({ containerId: sessionId }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].status).toBe('completed'); + expect(result.items[0].completedAt?.toISOString()).toBe(completedAt.toISOString()); + }); + + it('should apply excludeStatuses on the agent query branch', async () => { + await serverDB.transaction(async (trx) => { + await trx.insert(agents).values([{ id: 'status-agent', userId, title: 'Status Agent' }]); + await trx.insert(topics).values([ + { + id: 'agent-active', + userId, + agentId: 'status-agent', + status: 'active', + updatedAt: new Date('2024-01-01'), + }, + { + id: 'agent-completed', + userId, + agentId: 'status-agent', + status: 'completed', + updatedAt: new Date('2024-01-02'), + }, + ]); + }); + + const result = await topicModel.query({ + agentId: 'status-agent', + excludeStatuses: ['completed'], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe('agent-active'); + expect(result.total).toBe(1); + }); + + it('should apply excludeStatuses on the groupId query branch', async () => { + await serverDB.transaction(async (trx) => { + await trx + .insert(chatGroups) + .values([{ id: 'status-group', title: 'Status Group', userId }]); + await trx.insert(topics).values([ + { + id: 'group-active', + userId, + groupId: 'status-group', + status: 'active', + updatedAt: new Date('2024-01-01'), + }, + { + id: 'group-completed', + userId, + groupId: 'status-group', + status: 'completed', + updatedAt: new Date('2024-01-02'), + }, + ]); + }); + + const result = await topicModel.query({ + groupId: 'status-group', + excludeStatuses: ['completed'], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe('group-active'); + }); }); describe('query with agentId filter', () => { diff --git a/packages/database/src/models/topic.ts b/packages/database/src/models/topic.ts index 1135abbad2..54a6cc2b4e 100644 --- a/packages/database/src/models/topic.ts +++ b/packages/database/src/models/topic.ts @@ -28,6 +28,10 @@ interface QueryTopicParams { */ containerId?: string | null; current?: number; + /** + * Exclude topics by status (e.g. ['completed']) + */ + excludeStatuses?: string[]; /** * Exclude topics by trigger types (e.g. ['cron']) */ @@ -42,6 +46,10 @@ interface QueryTopicParams { */ isInbox?: boolean; pageSize?: number; + /** + * Include only topics matching the given trigger types (positive filter) + */ + triggers?: string[]; } export interface ListTopicsForMemoryExtractorCursor { @@ -63,16 +71,27 @@ export class TopicModel { agentId, containerId, current = 0, + excludeStatuses, excludeTriggers, pageSize = 9999, groupId, isInbox, + triggers, }: QueryTopicParams = {}) => { const offset = current * pageSize; const excludeTriggerCondition = excludeTriggers && excludeTriggers.length > 0 ? or(isNull(topics.trigger), not(inArray(topics.trigger, excludeTriggers))) : undefined; + const triggerCondition = + triggers && triggers.length > 0 ? inArray(topics.trigger, triggers) : undefined; + const excludeStatusCondition = + excludeStatuses && excludeStatuses.length > 0 + ? or( + isNull(topics.status), + not(inArray(topics.status, excludeStatuses as ('active' | 'completed' | 'archived')[])), + ) + : undefined; // If groupId is provided, query topics by groupId directly if (groupId) { @@ -80,16 +99,20 @@ export class TopicModel { eq(topics.userId, this.userId), eq(topics.groupId, groupId), excludeTriggerCondition, + triggerCondition, + excludeStatusCondition, ); const [items, totalResult] = await Promise.all([ this.db .select({ + completedAt: topics.completedAt, createdAt: topics.createdAt, favorite: topics.favorite, historySummary: topics.historySummary, id: topics.id, metadata: topics.metadata, + status: topics.status, title: topics.title, updatedAt: topics.updatedAt, }) @@ -145,26 +168,36 @@ export class TopicModel { // Fetch items and total count in parallel // Include sessionId and agentId for migration detection + const agentWhere = and( + eq(topics.userId, this.userId), + agentCondition, + excludeTriggerCondition, + triggerCondition, + excludeStatusCondition, + ); + const [items, totalResult] = await Promise.all([ this.db .select({ + completedAt: topics.completedAt, createdAt: topics.createdAt, favorite: topics.favorite, historySummary: topics.historySummary, id: topics.id, metadata: topics.metadata, + status: topics.status, title: topics.title, updatedAt: topics.updatedAt, }) .from(topics) - .where(and(eq(topics.userId, this.userId), agentCondition, excludeTriggerCondition)) + .where(agentWhere) .orderBy(desc(topics.favorite), desc(topics.updatedAt)) .limit(pageSize) .offset(offset), this.db .select({ count: count(topics.id) }) .from(topics) - .where(and(eq(topics.userId, this.userId), agentCondition, excludeTriggerCondition)), + .where(agentWhere), ]); return { items, total: totalResult[0].count }; @@ -175,18 +208,22 @@ export class TopicModel { eq(topics.userId, this.userId), this.matchContainer(containerId), excludeTriggerCondition, + triggerCondition, + excludeStatusCondition, ); const [items, totalResult] = await Promise.all([ this.db .select({ agentId: topics.agentId, + completedAt: topics.completedAt, createdAt: topics.createdAt, favorite: topics.favorite, historySummary: topics.historySummary, id: topics.id, metadata: topics.metadata, sessionId: topics.sessionId, + status: topics.status, title: topics.title, updatedAt: topics.updatedAt, }) diff --git a/packages/types/src/topic/topic.ts b/packages/types/src/topic/topic.ts index 927cc3639d..127055f9cc 100644 --- a/packages/types/src/topic/topic.ts +++ b/packages/types/src/topic/topic.ts @@ -104,11 +104,15 @@ export interface ChatTopicSummary { provider: string; } +export type ChatTopicStatus = 'active' | 'completed' | 'archived'; + export interface ChatTopic extends Omit { + completedAt?: Date | null; favorite?: boolean; historySummary?: string; metadata?: ChatTopicMetadata; sessionId?: string; + status?: ChatTopicStatus | null; title: string; trigger?: string | null; } @@ -160,6 +164,10 @@ export interface CreateTopicParams { export interface QueryTopicParams { agentId?: string | null; current?: number; + /** + * Exclude topics by status (e.g. ['completed']) + */ + excludeStatuses?: string[]; /** * Exclude topics by trigger types (e.g. ['cron']) */ @@ -174,6 +182,10 @@ export interface QueryTopicParams { */ isInbox?: boolean; pageSize?: number; + /** + * Include only topics matching the given trigger types (positive filter) + */ + triggers?: string[]; } /** diff --git a/packages/types/src/user/preference.ts b/packages/types/src/user/preference.ts index 367d714866..67f1167f93 100644 --- a/packages/types/src/user/preference.ts +++ b/packages/types/src/user/preference.ts @@ -75,6 +75,10 @@ export interface UserPreference { */ telemetry?: boolean | null; topicGroupMode?: TopicGroupMode; + /** + * whether to include completed topics in the topic list + */ + topicIncludeCompleted?: boolean; topicSortBy?: TopicSortBy; /** * whether to use cmd + enter to send message @@ -138,6 +142,7 @@ export const UserPreferenceSchema = z lab: UserLabSchema.optional(), telemetry: z.boolean().nullable(), topicGroupMode: z.enum(['byTime', 'byProject', 'flat']).optional(), + topicIncludeCompleted: z.boolean().optional(), topicSortBy: z.enum(['createdAt', 'updatedAt']).optional(), useCmdEnterToSend: z.boolean().optional(), }) diff --git a/src/hooks/useFetchTopics.ts b/src/hooks/useFetchTopics.ts index 9ebf34b175..7b9cc995c3 100644 --- a/src/hooks/useFetchTopics.ts +++ b/src/hooks/useFetchTopics.ts @@ -7,7 +7,10 @@ import { systemStatusSelectors } from '@/store/global/selectors'; /** * Fetch topics for the current session (agent or group) */ -export const useFetchTopics = (options?: { excludeTriggers?: string[] }) => { +export const useFetchTopics = (options?: { + excludeStatuses?: string[]; + excludeTriggers?: string[]; +}) => { const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent); const [activeAgentId, activeGroupId, useFetchTopicsHook] = useChatStore((s) => [ s.activeAgentId, @@ -20,6 +23,9 @@ export const useFetchTopics = (options?: { excludeTriggers?: string[] }) => { // If in group session, use groupId; otherwise use agentId const { isValidating, data } = useFetchTopicsHook(true, { agentId: activeAgentId, + ...(options?.excludeStatuses && options.excludeStatuses.length > 0 + ? { excludeStatuses: options.excludeStatuses } + : {}), ...(options?.excludeTriggers && options.excludeTriggers.length > 0 ? { excludeTriggers: options.excludeTriggers } : {}), diff --git a/src/locales/default/topic.ts b/src/locales/default/topic.ts index 5a220b6b78..25a143db21 100644 --- a/src/locales/default/topic.ts +++ b/src/locales/default/topic.ts @@ -15,6 +15,8 @@ export default { 'actions.duplicate': 'Duplicate', 'actions.favorite': 'Favorite', 'actions.unfavorite': 'Unfavorite', + 'actions.markCompleted': 'Mark as Completed', + 'actions.unmarkCompleted': 'Mark as Active', 'actions.export': 'Export Topics', 'actions.import': 'Import Conversation', 'actions.openInNewTab': 'Open in New Tab', @@ -26,10 +28,12 @@ export default { 'duplicateLoading': 'Copying Topic...', 'duplicateSuccess': 'Topic Copied Successfully', 'favorite': 'Favorite', + 'filter.filter': 'Filter', 'filter.groupMode.byProject': 'By project', 'filter.groupMode.byTime': 'By time', 'filter.groupMode.flat': 'Flat', 'filter.organize': 'Organize', + 'filter.showCompleted': 'Include Completed', 'filter.sort': 'Sort by', 'filter.sortBy.createdAt': 'Created time', 'filter.sortBy.updatedAt': 'Updated time', diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx index f943366f66..1553d852e2 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx @@ -165,6 +165,7 @@ const Content = memo(({ open, searchKeyword }) => { fav={topic.favorite} id={topic.id} metadata={topic.metadata} + status={topic.status} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx index 9d23a8efb3..95118991d5 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx @@ -1,6 +1,7 @@ +import type { ChatTopicMetadata, ChatTopicStatus } from '@lobechat/types'; import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui'; import { createStaticStyles, cssVar, keyframes, useTheme } from 'antd-style'; -import { HashIcon, MessageSquareDashed } from 'lucide-react'; +import { CheckCircle2, HashIcon, MessageSquareDashed } from 'lucide-react'; import { memo, Suspense, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,7 +15,6 @@ import { useAgentStore } from '@/store/agent'; import { useChatStore } from '@/store/chat'; import { operationSelectors } from '@/store/chat/selectors'; import { useElectronStore } from '@/store/electron'; -import type { ChatTopicMetadata } from '@/types/topic'; import { useTopicNavigation } from '../../hooks/useTopicNavigation'; import ThreadList from '../../TopicListContent/ThreadList'; @@ -75,11 +75,12 @@ interface TopicItemProps { fav?: boolean; id?: string; metadata?: ChatTopicMetadata; + status?: ChatTopicStatus | null; threadId?: string; title: string; } -const TopicItem = memo(({ id, title, fav, active, threadId, metadata }) => { +const TopicItem = memo(({ id, title, fav, active, threadId, metadata, status }) => { const { t } = useTranslation('topic'); const { isDarkMode } = useTheme(); const activeAgentId = useAgentStore((s) => s.activeAgentId); @@ -147,9 +148,12 @@ const TopicItem = memo(({ id, title, fav, active, threadId, meta const { dropdownMenu } = useTopicItemDropdownMenu({ fav, id, + status, title, }); + const isCompleted = status === 'completed'; + const hasUnread = id && isUnreadCompleted; const unreadIcon = ( @@ -212,6 +216,15 @@ const TopicItem = memo(({ id, title, fav, active, threadId, meta /> ); } + if (isCompleted) { + return ( + + ); + } if (hasUnread) return unreadIcon; if (metadata?.bot?.platform) { const ProviderIcon = getPlatformIcon(metadata.bot!.platform); diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx index 298c3b7852..f908fa873f 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx @@ -1,7 +1,10 @@ +import type { ChatTopicStatus } from '@lobechat/types'; import { type MenuProps } from '@lobehub/ui'; import { Icon } from '@lobehub/ui'; import { App } from 'antd'; import { + CheckCircle2, + Circle, ExternalLink, Link2, LucideCopy, @@ -29,10 +32,16 @@ import { useGlobalStore } from '@/store/global'; interface TopicItemDropdownMenuProps { fav?: boolean; id?: string; + status?: ChatTopicStatus | null; title: string; } -export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMenuProps) => { +export const useTopicItemDropdownMenu = ({ + fav, + id, + status, + title, +}: TopicItemDropdownMenuProps) => { const { t } = useTranslation(['topic', 'common']); const { modal, message } = App.useApp(); const navigate = useNavigate(); @@ -42,14 +51,25 @@ export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMe const addTab = useElectronStore((s) => s.addTab); const appOrigin = useAppOrigin(); - const [autoRenameTopicTitle, duplicateTopic, removeTopic, favoriteTopic, updateTopicTitle] = - useChatStore((s) => [ - s.autoRenameTopicTitle, - s.duplicateTopic, - s.removeTopic, - s.favoriteTopic, - s.updateTopicTitle, - ]); + const [ + autoRenameTopicTitle, + duplicateTopic, + removeTopic, + favoriteTopic, + markTopicCompleted, + unmarkTopicCompleted, + updateTopicTitle, + ] = useChatStore((s) => [ + s.autoRenameTopicTitle, + s.duplicateTopic, + s.removeTopic, + s.favoriteTopic, + s.markTopicCompleted, + s.unmarkTopicCompleted, + s.updateTopicTitle, + ]); + + const isCompleted = status === 'completed'; const handleOpenShareModal = useCallback(() => { if (!id) return; @@ -60,6 +80,21 @@ export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMe if (!id) return []; return [ + { + icon: , + key: 'markCompleted', + label: isCompleted ? t('actions.unmarkCompleted') : t('actions.markCompleted'), + onClick: () => { + if (isCompleted) { + unmarkTopicCompleted(id); + } else { + markTopicCompleted(id); + } + }, + }, + { + type: 'divider' as const, + }, { icon: , key: 'favorite', @@ -177,12 +212,15 @@ export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMe }, [ id, fav, + isCompleted, title, activeAgentId, appOrigin, autoRenameTopicTitle, duplicateTopic, favoriteTopic, + markTopicCompleted, + unmarkTopicCompleted, removeTopic, updateTopicTitle, openTopicInNewWindow, diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx index 8dfa17595b..9eab873bbc 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx @@ -75,6 +75,7 @@ const GroupItem = memo(({ group, activeTopicId, activeT id={topic.id} key={topic.id} metadata={topic.metadata} + status={topic.status} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx index 5a6748084b..0978a3df4c 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx @@ -36,6 +36,7 @@ const GroupItem = memo(({ group, activeTopicId, activeT id={topic.id} key={topic.id} metadata={topic.metadata} + status={topic.status} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx index 244a8c468f..2ffc6b4aee 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx @@ -45,6 +45,7 @@ const FlatMode = memo(() => { id={topic.id} key={topic.id} metadata={topic.metadata} + status={topic.status} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx index 2f7deab808..c550fef08e 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx @@ -37,6 +37,7 @@ const SearchResult = memo(() => { id={topic.id} key={topic.id} metadata={topic.metadata} + status={topic.status} title={topic.title} /> ))} diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/index.tsx index 999f028697..799de8b353 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/index.tsx @@ -9,6 +9,8 @@ import SkeletonList from '@/features/NavPanel/components/SkeletonList'; import { useFetchTopics } from '@/hooks/useFetchTopics'; import { useChatStore } from '@/store/chat'; import { topicSelectors } from '@/store/chat/selectors'; +import { useUserStore } from '@/store/user'; +import { preferenceSelectors } from '@/store/user/selectors'; import Actions from './Actions'; import Filter from './Filter'; @@ -22,8 +24,12 @@ interface TopicProps { const Topic = memo(({ itemKey }) => { const { t } = useTranslation(['topic', 'common']); const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]); + const includeCompleted = useUserStore(preferenceSelectors.topicIncludeCompleted); const dropdownMenu = useTopicActionsDropdownMenu(); - const { isRevalidating } = useFetchTopics({ excludeTriggers: ['cron', 'eval'] }); + const { isRevalidating } = useFetchTopics({ + excludeStatuses: includeCompleted ? undefined : ['completed'], + excludeTriggers: ['cron', 'eval'], + }); return ( { const { t } = useTranslation('topic'); - const [topicGroupMode, topicSortBy, updatePreference] = useUserStore((s) => [ - preferenceSelectors.topicGroupMode(s), - preferenceSelectors.topicSortBy(s), - s.updatePreference, - ]); + const [topicGroupMode, topicSortBy, topicIncludeCompleted, updatePreference] = useUserStore( + (s) => [ + preferenceSelectors.topicGroupMode(s), + preferenceSelectors.topicSortBy(s), + preferenceSelectors.topicIncludeCompleted(s), + s.updatePreference, + ], + ); return useMemo(() => { const groupModes: TopicGroupMode[] = ['byTime', 'byProject', 'flat']; @@ -49,6 +52,22 @@ export const useTopicFilterDropdownMenu = (): DropdownItem[] => { label: t('filter.sort'), type: 'group' as const, }, + { type: 'divider' as const }, + { + children: [ + { + icon: topicIncludeCompleted ? :
, + key: 'showCompleted', + label: t('filter.showCompleted'), + onClick: () => { + updatePreference({ topicIncludeCompleted: !topicIncludeCompleted }); + }, + }, + ], + key: 'filter', + label: t('filter.filter'), + type: 'group' as const, + }, ]; - }, [topicGroupMode, topicSortBy, updatePreference, t]); + }, [topicGroupMode, topicSortBy, topicIncludeCompleted, updatePreference, t]); }; diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx index b10b8037e1..84e541c16e 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/AllTopicsDrawer/Content.tsx @@ -164,6 +164,7 @@ const Content = memo(({ open, searchKeyword }) => { active={activeTopicId === topic.id} fav={topic.favorite} id={topic.id} + status={topic.status} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx index 9e40713c07..f36b01ff2b 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx @@ -1,6 +1,7 @@ +import type { ChatTopicStatus } from '@lobechat/types'; import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; -import { HashIcon, Loader2Icon, MessageSquareDashed } from 'lucide-react'; +import { CheckCircle2, HashIcon, Loader2Icon, MessageSquareDashed } from 'lucide-react'; import { AnimatePresence, m } from 'motion/react'; import { memo, Suspense, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -61,11 +62,12 @@ interface TopicItemProps { active?: boolean; fav?: boolean; id?: string; + status?: ChatTopicStatus | null; threadId?: string; title: string; } -const TopicItem = memo(({ id, title, fav, active, threadId }) => { +const TopicItem = memo(({ id, title, fav, active, threadId, status }) => { const { t } = useTranslation('topic'); const toggleMobileTopic = useGlobalStore((s) => s.toggleMobileTopic); const [activeGroupId, switchTopic] = useAgentGroupStore((s) => [s.activeGroupId, s.switchTopic]); @@ -137,9 +139,12 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => const dropdownMenu = useTopicItemDropdownMenu({ id, + status, toggleEditing, }); + const isCompleted = status === 'completed'; + const hasUnread = id && isUnreadCompleted; const infoColor = cssVar.colorInfo; const unreadNode = ( @@ -222,13 +227,25 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => disabled={editing} href={!editing ? href : undefined} title={title === '...' ? : title} - icon={ - isLoading ? ( - - ) : ( + icon={(() => { + if (isLoading) { + return ( + + ); + } + if (isCompleted) { + return ( + + ); + } + return ( - ) - } + ); + })()} slots={{ iconPostfix: unreadNode, }} diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx index 767b2c8d9d..d13f45c793 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx @@ -1,7 +1,18 @@ +import type { ChatTopicStatus } from '@lobechat/types'; import { type MenuProps } from '@lobehub/ui'; import { Icon } from '@lobehub/ui'; import { App } from 'antd'; -import { ExternalLink, Link2, LucideCopy, PanelTop, PencilLine, Trash, Wand2 } from 'lucide-react'; +import { + CheckCircle2, + Circle, + ExternalLink, + Link2, + LucideCopy, + PanelTop, + PencilLine, + Trash, + Wand2, +} from 'lucide-react'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -16,11 +27,13 @@ import { useGlobalStore } from '@/store/global'; interface TopicItemDropdownMenuProps { id?: string; + status?: ChatTopicStatus | null; toggleEditing: (visible?: boolean) => void; } export const useTopicItemDropdownMenu = ({ id, + status, toggleEditing, }: TopicItemDropdownMenuProps): (() => MenuProps['items']) => { const { t } = useTranslation(['topic', 'common']); @@ -32,16 +45,41 @@ export const useTopicItemDropdownMenu = ({ const addTab = useElectronStore((s) => s.addTab); const appOrigin = useAppOrigin(); - const [autoRenameTopicTitle, duplicateTopic, removeTopic] = useChatStore((s) => [ + const [ + autoRenameTopicTitle, + duplicateTopic, + removeTopic, + markTopicCompleted, + unmarkTopicCompleted, + ] = useChatStore((s) => [ s.autoRenameTopicTitle, s.duplicateTopic, s.removeTopic, + s.markTopicCompleted, + s.unmarkTopicCompleted, ]); + const isCompleted = status === 'completed'; + return useCallback(() => { if (!id) return []; return [ + { + icon: , + key: 'markCompleted', + label: isCompleted ? t('actions.unmarkCompleted') : t('actions.markCompleted'), + onClick: () => { + if (isCompleted) { + unmarkTopicCompleted(id); + } else { + markTopicCompleted(id); + } + }, + }, + { + type: 'divider' as const, + }, { icon: , key: 'autoRename', @@ -131,10 +169,13 @@ export const useTopicItemDropdownMenu = ({ ].filter(Boolean) as MenuProps['items']; }, [ id, + isCompleted, activeGroupId, appOrigin, autoRenameTopicTitle, duplicateTopic, + markTopicCompleted, + unmarkTopicCompleted, removeTopic, openGroupTopicInNewWindow, addTab, diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx index 76a350dfc8..3534a3cc7b 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx @@ -42,6 +42,7 @@ const GroupItem = memo(({ group, activeTopicId, activeThreadId } fav={topic.favorite} id={topic.id} key={topic.id} + status={topic.status} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx index 46d3ba6b11..7307077394 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/FlatMode/index.tsx @@ -44,6 +44,7 @@ const FlatMode = memo(() => { fav={topic.favorite} id={topic.id} key={topic.id} + status={topic.status} threadId={activeThreadId} title={topic.title} /> diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx index 3d06b33a71..9953f91c99 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/TopicListContent/SearchResult/index.tsx @@ -36,6 +36,7 @@ const SearchResult = memo(() => { fav={topic.favorite} id={topic.id} key={topic.id} + status={topic.status} title={topic.title} /> ))} diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/index.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/index.tsx index 650c56b470..5433f3e2c0 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/index.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/index.tsx @@ -9,6 +9,8 @@ import SkeletonList from '@/features/NavPanel/components/SkeletonList'; import { useFetchTopics } from '@/hooks/useFetchTopics'; import { useChatStore } from '@/store/chat'; import { topicSelectors } from '@/store/chat/selectors'; +import { useUserStore } from '@/store/user'; +import { preferenceSelectors } from '@/store/user/selectors'; import Actions from './Actions'; import Filter from './Filter'; @@ -22,8 +24,11 @@ interface TopicProps { const Topic = memo(({ itemKey }) => { const { t } = useTranslation(['topic', 'common']); const [topicCount] = useChatStore((s) => [topicSelectors.currentTopicCount(s)]); + const includeCompleted = useUserStore(preferenceSelectors.topicIncludeCompleted); const dropdownMenu = useTopicActionsDropdownMenu(); - const { isRevalidating } = useFetchTopics(); + const { isRevalidating } = useFetchTopics({ + excludeStatuses: includeCompleted ? undefined : ['completed'], + }); return ( { const { t } = useTranslation('topic'); - const [topicGroupMode, topicSortBy, updatePreference] = useUserStore((s) => [ - preferenceSelectors.topicGroupMode(s), - preferenceSelectors.topicSortBy(s), - s.updatePreference, - ]); + const [topicGroupMode, topicSortBy, topicIncludeCompleted, updatePreference] = useUserStore( + (s) => [ + preferenceSelectors.topicGroupMode(s), + preferenceSelectors.topicSortBy(s), + preferenceSelectors.topicIncludeCompleted(s), + s.updatePreference, + ], + ); return useMemo(() => { const groupModes: TopicGroupMode[] = ['byTime', 'byProject', 'flat']; @@ -49,6 +52,22 @@ export const useTopicFilterDropdownMenu = (): DropdownItem[] => { label: t('filter.sort'), type: 'group' as const, }, + { type: 'divider' as const }, + { + children: [ + { + icon: topicIncludeCompleted ? :
, + key: 'showCompleted', + label: t('filter.showCompleted'), + onClick: () => { + updatePreference({ topicIncludeCompleted: !topicIncludeCompleted }); + }, + }, + ], + key: 'filter', + label: t('filter.filter'), + type: 'group' as const, + }, ]; - }, [topicGroupMode, topicSortBy, updatePreference, t]); + }, [topicGroupMode, topicSortBy, topicIncludeCompleted, updatePreference, t]); }; diff --git a/src/server/routers/lambda/topic.ts b/src/server/routers/lambda/topic.ts index 9a44284a80..132f02b870 100644 --- a/src/server/routers/lambda/topic.ts +++ b/src/server/routers/lambda/topic.ts @@ -234,19 +234,28 @@ export const topicRouter = router({ z.object({ agentId: z.string().nullable().optional(), current: z.number().optional(), + excludeStatuses: z.array(z.string()).optional(), excludeTriggers: z.array(z.string()).optional(), groupId: z.string().nullable().optional(), isInbox: z.boolean().optional(), pageSize: z.number().optional(), sessionId: z.string().nullable().optional(), + triggers: z.array(z.string()).optional(), }), ) .query(async ({ input, ctx }) => { - const { sessionId, isInbox, groupId, excludeTriggers, ...rest } = input; + const { sessionId, isInbox, groupId, excludeStatuses, excludeTriggers, triggers, ...rest } = + input; // If groupId is provided, query by groupId directly if (groupId) { - const result = await ctx.topicModel.query({ excludeTriggers, groupId, ...rest }); + const result = await ctx.topicModel.query({ + excludeStatuses, + excludeTriggers, + groupId, + triggers, + ...rest, + }); return { items: result.items, total: result.total }; } @@ -259,8 +268,10 @@ export const topicRouter = router({ const result = await ctx.topicModel.query({ ...rest, agentId: effectiveAgentId, + excludeStatuses, excludeTriggers, isInbox, + triggers, }); // Runtime migration: backfill agentId for ALL legacy topics and messages under this agent @@ -524,6 +535,7 @@ export const topicRouter = router({ id: z.string(), value: z.object({ agentId: z.string().optional(), + completedAt: z.date().nullable().optional(), favorite: z.boolean().optional(), historySummary: z.string().optional(), messages: z.array(z.string()).optional(), @@ -534,6 +546,7 @@ export const topicRouter = router({ }) .optional(), sessionId: z.string().optional(), + status: z.enum(['active', 'completed', 'archived']).nullable().optional(), title: z.string().optional(), }), }), diff --git a/src/services/topic/index.ts b/src/services/topic/index.ts index 355d9079eb..bed8251921 100644 --- a/src/services/topic/index.ts +++ b/src/services/topic/index.ts @@ -37,10 +37,12 @@ export class TopicService { return lambdaClient.topic.getTopics.query({ agentId: params.agentId, current: params.current, + excludeStatuses: params.excludeStatuses, excludeTriggers: params.excludeTriggers, groupId: params.groupId, isInbox: params.isInbox, pageSize: params.pageSize, + triggers: params.triggers, }) as any; }; diff --git a/src/store/chat/slices/topic/action.ts b/src/store/chat/slices/topic/action.ts index 389ceb7d14..2dfe6e4dd0 100644 --- a/src/store/chat/slices/topic/action.ts +++ b/src/store/chat/slices/topic/action.ts @@ -234,6 +234,20 @@ export class ChatTopicActionImpl { }); }; + markTopicCompleted = async (id: string): Promise => { + await this.#get().internal_updateTopic(id, { + completedAt: new Date(), + status: 'completed', + }); + }; + + unmarkTopicCompleted = async (id: string): Promise => { + await this.#get().internal_updateTopic(id, { + completedAt: null, + status: 'active', + }); + }; + favoriteTopic = async (id: string, favorite: boolean): Promise => { const { activeAgentId } = this.#get(); await this.#get().internal_updateTopic(id, { favorite }); @@ -303,12 +317,14 @@ export class ChatTopicActionImpl { enable: boolean, { agentId, + excludeStatuses, excludeTriggers, groupId, pageSize: customPageSize, isInbox, }: { agentId?: string; + excludeStatuses?: string[]; excludeTriggers?: string[]; groupId?: string; isInbox?: boolean; @@ -318,6 +334,8 @@ export class ChatTopicActionImpl { const pageSize = customPageSize || 20; const effectiveExcludeTriggers = excludeTriggers && excludeTriggers.length > 0 ? excludeTriggers : undefined; + const effectiveExcludeStatuses = + excludeStatuses && excludeStatuses.length > 0 ? excludeStatuses : undefined; // Use topicMapKey to generate the container key for topic data map const containerKey = topicMapKey({ agentId, groupId }); const hasValidContainer = !!(groupId || agentId); @@ -331,6 +349,7 @@ export class ChatTopicActionImpl { isInbox, pageSize, ...(effectiveExcludeTriggers ? { excludeTriggers: effectiveExcludeTriggers } : {}), + ...(effectiveExcludeStatuses ? { excludeStatuses: effectiveExcludeStatuses } : {}), }, ] : null, @@ -353,6 +372,7 @@ export class ChatTopicActionImpl { const result = await topicService.getTopics({ agentId, current: 0, + excludeStatuses: effectiveExcludeStatuses, excludeTriggers: effectiveExcludeTriggers, groupId, isInbox, @@ -385,6 +405,7 @@ export class ChatTopicActionImpl { ...this.#get().topicDataMap, [containerKey]: { currentPage: 0, + excludeStatuses: effectiveExcludeStatuses, excludeTriggers: effectiveExcludeTriggers, hasMore, isExpandingPageSize: false, @@ -426,9 +447,11 @@ export class ChatTopicActionImpl { try { const pageSize = useGlobalStore.getState().status.topicPageSize || 20; const excludeTriggers = currentData?.excludeTriggers; + const excludeStatuses = currentData?.excludeStatuses; const result = await topicService.getTopics({ agentId: activeAgentId, current: nextPage, + excludeStatuses, excludeTriggers, groupId: activeGroupId, pageSize, @@ -443,6 +466,7 @@ export class ChatTopicActionImpl { ...this.#get().topicDataMap, [key]: { currentPage: nextPage, + excludeStatuses, excludeTriggers, hasMore, isLoadingMore: false, diff --git a/src/store/chat/slices/topic/initialState.ts b/src/store/chat/slices/topic/initialState.ts index b1c16d1c57..2eb6cae10d 100644 --- a/src/store/chat/slices/topic/initialState.ts +++ b/src/store/chat/slices/topic/initialState.ts @@ -5,6 +5,7 @@ import { type ChatTopic } from '@/types/topic'; */ export interface TopicData { currentPage: number; + excludeStatuses?: string[]; excludeTriggers?: string[]; hasMore: boolean; isExpandingPageSize?: boolean; diff --git a/src/store/user/slices/preference/selectors/preference.ts b/src/store/user/slices/preference/selectors/preference.ts index db3a9a5f7d..b6c5ab10e5 100644 --- a/src/store/user/slices/preference/selectors/preference.ts +++ b/src/store/user/slices/preference/selectors/preference.ts @@ -6,6 +6,8 @@ const useCmdEnterToSend = (s: UserStore): boolean => s.preference.useCmdEnterToS const topicGroupMode = (s: UserStore) => s.preference.topicGroupMode || DEFAULT_PREFERENCE.topicGroupMode!; const topicSortBy = (s: UserStore) => s.preference.topicSortBy || DEFAULT_PREFERENCE.topicSortBy!; +const topicIncludeCompleted = (s: UserStore): boolean => + s.preference.topicIncludeCompleted ?? false; const hideSyncAlert = (s: UserStore) => s.preference.hideSyncAlert; @@ -26,6 +28,7 @@ export const preferenceSelectors = { shouldTriggerFileInKnowledgeBaseTip, showUploadFileInKnowledgeBaseTip, topicGroupMode, + topicIncludeCompleted, topicSortBy, useCmdEnterToSend, };