Compare commits

...

7 commits

Author SHA1 Message Date
Arvin Xu
c0db58e622
feat(topic): add completed status with dropdown action and filter (#14005)
Some checks are pending
E2E CI / Check Duplicate Run (push) Waiting to run
E2E CI / Test Web App (push) Blocked by required conditions
Release Desktop Canary / Calculate Canary Version (push) Waiting to run
Release Desktop Canary / Code quality check (push) Blocked by required conditions
Release Desktop Canary / Build Desktop App (push) Blocked by required conditions
Release Desktop Canary / Merge macOS Release Files (push) Blocked by required conditions
Release Desktop Canary / Publish Canary Release (push) Blocked by required conditions
Release Desktop Canary / Publish to S3 (push) Blocked by required conditions
Release Desktop Canary / Cleanup Old Canary Releases (push) Blocked by required conditions
Release ModelBank / Build ModelBank (push) Waiting to run
Release ModelBank / Publish ModelBank (push) Blocked by required conditions
Test CI / Check Duplicate Run (push) Waiting to run
Test CI / Test Packages (push) Blocked by required conditions
Test CI / Test App (shard 1/3) (push) Blocked by required conditions
Test CI / Test App (shard 2/3) (push) Blocked by required conditions
Test CI / Test App (shard 3/3) (push) Blocked by required conditions
Test CI / Merge and Upload App Coverage (push) Blocked by required conditions
Test CI / Test Desktop App (push) Blocked by required conditions
Test CI / Test Database (push) Blocked by required conditions
*  feat(topic): add completed status with dropdown action and filter

- Surface ChatTopicStatus (active/completed/archived) on topic list items and pass to dropdown menu
- Add markTopicCompleted / unmarkTopicCompleted store actions wired into the topic item dropdown
- Show CheckCircle2 icon on completed topics in the sidebar list
- Add topicIncludeCompleted user preference (default false) and an "Include Completed" toggle in the topic filter menu (agent + group routes)
- Wire excludeStatuses and triggers filters through TopicModel, TRPC router, service, and store SWR keys so completed topics are excluded by default

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🌐 i18n(topic): add zh-CN/en-US for completed status keys

Translate actions.markCompleted / actions.unmarkCompleted and filter.filter / filter.showCompleted for dev preview. CI's pnpm i18n will fill in remaining locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(topic): scope completed exclusion to routes with the toggle

Move the topicIncludeCompleted preference read out of the chat-store useFetchTopics action and into the (main) agent/group sidebars where the "Include Completed" filter actually lives. Popup and mobile topic views call useFetchTopics without excludeStatuses, so completed topics remain reachable on surfaces that don't expose the toggle (e.g. the popup window for a deep-linked completed topic, the mobile TopicModal).

Also switch ChatTopicStatus imports in the topic item / dropdown files to @lobechat/types to match the rest of the topic-feature imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  test(topic-model): cover excludeStatuses + triggers filters

Add cases to the TopicModel.query suite for the new params introduced alongside the topic.status column:
- triggers (positive trigger filter) on the container branch
- excludeStatuses on the container, agent, and groupId branches (verifies null status rows are still returned)
- status / completedAt are populated on returned items

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(topic): move "Mark Completed" to top of agent topic dropdown

Promote the completed-status toggle to the first menu item, with a divider before favorite, so the most-used status action sits at the top of the dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:37:09 +08:00
YuTengjing
61224fe76c
🐛 fix(auth): return 401 for expired OIDC JWT instead of 500 (#14014) 2026-04-21 16:43:57 +08:00
Innei
8119789849
🐛 fix(model-bank): add repository metadata for provenance (#14018) 2026-04-21 15:59:55 +08:00
Innei
1ffd01a9eb
🐛 fix(model-bank): publish initial npm package publicly (#14017) 2026-04-21 15:50:28 +08:00
Innei
9d3696ceef
👷 build(model-bank): automate npm release (#14015) 2026-04-21 15:38:04 +08:00
LiJian
595193ce62
🐛 fix: clarify lobe-gtd and lobe-cron tool descriptions to prevent routing confusion (#14013)
When users say "daily task" or "routine", the model confused lobe-gtd (one-time todos) with lobe-cron (recurring automation), often falling back to user-memory or GTD instead of cron.

Fixes LOBE-7486

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 15:30:45 +08:00
LiJian
665b482390
🐛 fix: inject timezone and cron jobs list into cron tool system prompt (#14012)
* 🐛 fix: inject timezone and cron jobs list into cron tool system prompt

Add {{timezone}} to cron systemRole session_context so the model knows
the user's local timezone when creating scheduled tasks. Wire up the
{{CRON_JOBS_LIST}} placeholder that was already referenced in the
systemRole but never populated — now fetches the agent's existing cron
jobs via tRPC and injects them, following the same pattern as CREDS_LIST.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: limit cron jobs context to 4 items to save context window

Only inject a preview of up to 4 cron jobs into the system prompt.
When there are more, append a hint directing the model to call
listCronJobs API for the full list. This avoids bloating the context
window for agents with many scheduled tasks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 15:25:55 +08:00
43 changed files with 817 additions and 76 deletions

View file

@ -1,7 +1,7 @@
name: Release ModelBank
permissions:
contents: write
contents: read
id-token: write
on:
@ -41,15 +41,12 @@ jobs:
publish:
name: Publish ModelBank
if: ${{ github.event_name == 'workflow_dispatch' }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
@ -63,27 +60,70 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Bump patch version
id: version
run: |
npm version patch --no-git-tag-version --prefix packages/model-bank
echo "version=$(node -p 'require(\"./packages/model-bank/package.json\").version')" >> "$GITHUB_OUTPUT"
- name: Build package
run: pnpm --filter model-bank build
- name: Prepare publish package
id: version
run: |
BASE_VERSION=$(node -p "require('./packages/model-bank/package.json').version.split('.').slice(0, 2).join('.')")
MODEL_BANK_VERSION="${BASE_VERSION}.$(date -u +%Y%m%d%H%M%S)"
export MODEL_BANK_VERSION
node <<'NODE'
const fs = require('node:fs');
const packagePath = 'packages/model-bank/package.json';
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const toDistExport = (sourcePath) => sourcePath.replace('./src/', './dist/').replace(/\.ts$/, '.mjs');
packageJson.version = process.env.MODEL_BANK_VERSION;
packageJson.type = 'module';
packageJson.main = './dist/index.mjs';
packageJson.types = './dist/index.d.mts';
packageJson.files = ['dist'];
packageJson.repository = {
type: 'git',
url: 'https://github.com/lobehub/lobehub',
directory: 'packages/model-bank',
};
packageJson.exports = Object.fromEntries(
Object.entries(packageJson.exports).map(([key, value]) => {
if (typeof value !== 'string') return [key, value];
const distPath = toDistExport(value);
return [
key,
{
types: distPath.replace(/\.mjs$/, '.d.mts'),
import: distPath,
default: distPath,
},
];
}),
);
delete packageJson.private;
delete packageJson.devDependencies;
delete packageJson.scripts;
if (packageJson.dependencies) {
delete packageJson.dependencies['@lobechat/business-const'];
if (Object.keys(packageJson.dependencies).length === 0) {
delete packageJson.dependencies;
}
}
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
echo "version=${MODEL_BANK_VERSION}" >> "$GITHUB_OUTPUT"
echo "Prepared model-bank@${MODEL_BANK_VERSION}"
- name: Publish to npm
run: npm publish --provenance
run: npm publish --provenance --access public
working-directory: packages/model-bank
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Commit version bump
env:
MODEL_BANK_VERSION: ${{ steps.version.outputs.version }}
run: |
git config user.name "lobehubbot"
git config user.email "i@lobehub.com"
git add packages/model-bank/package.json
git commit -m "🔖 chore(model-bank): release v${MODEL_BANK_VERSION}"
git push

View file

@ -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",

View file

@ -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": "按更新时间",

View file

@ -0,0 +1,25 @@
import type { CronJobSummaryForContext } from './types';
const formatCronJob = (job: CronJobSummaryForContext): string => {
const status = job.enabled ? 'enabled' : 'disabled';
const execInfo =
job.remainingExecutions != null ? `${job.remainingExecutions} remaining` : 'unlimited';
const lastRun = job.lastExecutedAt ?? 'never';
const desc = job.description ? ` - ${job.description}` : '';
return ` - ${job.name || 'Unnamed'} (id: ${job.id}): ${job.cronPattern} [${job.timezone}] [${status}, ${execInfo}, ${job.totalExecutions} completed, last run: ${lastRun}]${desc}`;
};
export const generateCronJobsList = (jobs: CronJobSummaryForContext[], total?: number): string => {
if (jobs.length === 0) {
return 'No scheduled tasks configured for this agent.';
}
const lines = jobs.map(formatCronJob);
if (total && total > jobs.length) {
lines.push(` (showing ${jobs.length} of ${total} tasks — use listCronJobs to see all)`);
}
return lines.join('\n');
};

View file

@ -1,4 +1,5 @@
export { CronExecutionRuntime, type ICronService } from './ExecutionRuntime';
export { generateCronJobsList } from './helpers';
export { CronIdentifier, CronManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {

View file

@ -10,7 +10,7 @@ export const CronManifest: BuiltinToolManifest = {
api: [
{
description:
'Create a new scheduled task for the current agent. The task will run automatically at the specified schedule. Minimum interval is 30 minutes.',
'Create a new automated recurring task. Use this when the user wants something done regularly/repeatedly — e.g., "set as daily task", "do this every morning", "make it a routine", "daily briefing", "weekly summary". The task will run automatically at the specified schedule without user intervention. Minimum interval is 30 minutes.',
name: CronApiName.createCronJob,
parameters: {
additionalProperties: false,
@ -215,7 +215,7 @@ export const CronManifest: BuiltinToolManifest = {
meta: {
avatar: '⏰',
description:
'Manage scheduled tasks that run automatically at specified times. Create, update, enable/disable, and monitor recurring tasks for your agents.',
'Automate recurring tasks that repeat on a schedule — daily routines, periodic reports, regular reminders, and any task the user wants done automatically every day/week/hour. Use this whenever the user says "daily task", "routine", "recurring", "every day", "every morning", "regular", "periodic", "set as daily", or implies something should happen repeatedly without manual trigger.',
title: 'Scheduled Tasks',
},
systemRole: systemPrompt,

View file

@ -4,12 +4,26 @@ export const systemPrompt = `You have access to a LobeHub Scheduled Tasks Tool.
Current user: {{username}}
Session date: {{date}}
Current agent: {{agent_id}}
User timezone: {{timezone}}
</session_context>
<existing_scheduled_tasks>
{{CRON_JOBS_LIST}}
</existing_scheduled_tasks>
<when_to_use>
**Use this tool (lobe-cron) when the user wants tasks to run automatically and repeatedly.** Common trigger phrases include:
- "daily task", "set as daily task", "make it a daily routine"
- "every day", "every morning", "every week", "every hour"
- "recurring", "routine", "regular", "periodic", "scheduled"
- "automatically do X at Y time"
- "remind me every day to..."
**Do NOT confuse with lobe-gtd.** GTD is for one-time planning and manual todo tracking. If the user says "daily task" or "routine task", they almost always mean automated repetition (lobe-cron), NOT a one-time todo item (lobe-gtd).
**Do NOT confuse with lobe-user-memory.** Memory is for saving user preferences and long-term knowledge. "Set as daily task" means schedule it to run repeatedly, NOT save it as a memory.
</when_to_use>
<core_capabilities>
1. **Create Tasks**: Set up recurring tasks with custom schedules (daily, hourly, weekly patterns)
2. **Manage Tasks**: Update, enable/disable, or delete existing scheduled tasks

View file

@ -236,7 +236,8 @@ export const GTDManifest: BuiltinToolManifest = {
identifier: GTDIdentifier,
meta: {
avatar: '✅',
description: 'Create plans, manage todo lists with status tracking, and run background tasks',
description:
'Plan and track one-time goals, manage todo checklists, and run background tasks. For tasks that need to be done once or tracked manually — NOT for tasks that should repeat automatically on a schedule (use lobe-cron for daily/weekly/recurring automation).',
title: 'GTD Tools',
},
systemRole: systemPrompt,

View file

@ -96,6 +96,7 @@ export const systemPrompt = `You have GTD (Getting Things Done) tools to help ma
- The task can be done in one action (rename, delete, send, search, etc.)
- The user just wants something done, not organized
- The task will be completed in this single conversation
- The user wants a task to repeat automatically on a schedule (daily/weekly/hourly) use **lobe-cron** instead. Keywords like "daily task", "routine", "recurring", "every day/morning/week", "set as daily", "make it regular" all indicate scheduled automation, not GTD todo management.
**Use Async Tasks when:**
- **The request requires gathering external information**: User wants you to research, investigate, or find information that you don't already know. This requires web searches, reading multiple sources, and synthesizing information.

View file

@ -17,6 +17,7 @@ export const DEFAULT_PREFERENCE: UserPreference = {
enableInputMarkdown: true,
},
topicGroupMode: 'byTime',
topicIncludeCompleted: false,
topicSortBy: 'updatedAt',
useCmdEnterToSend: false,
};

View file

@ -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', () => {

View file

@ -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,
})

View file

@ -104,11 +104,15 @@ export interface ChatTopicSummary {
provider: string;
}
export type ChatTopicStatus = 'active' | 'completed' | 'archived';
export interface ChatTopic extends Omit<BaseDataModel, 'meta'> {
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[];
}
/**

View file

@ -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(),
})

View file

@ -2,6 +2,7 @@ import { AgentRuntimeError } from '@lobechat/model-runtime';
import { ChatErrorType } from '@lobechat/types';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { validateOIDCJWT } from '@/libs/oidc-provider/jwt';
import { createErrorResponse } from '@/utils/errorResponse';
import { checkAuth, type RequestHandler } from './index';
@ -14,9 +15,15 @@ vi.mock('@lobechat/model-runtime', () => ({
}));
vi.mock('@lobechat/types', () => ({
ChatErrorType: { Unauthorized: 'Unauthorized', InternalServerError: 'InternalServerError' },
ChatErrorType: {
InternalServerError: 'InternalServerError',
Unauthorized: 'Unauthorized',
},
}));
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined);
vi.mock('@/utils/errorResponse', () => ({
createErrorResponse: vi.fn(),
}));
@ -83,6 +90,86 @@ describe('checkAuth', () => {
expect(mockHandler).not.toHaveBeenCalled();
});
it('should return unauthorized when OIDC JWT validation throws UNAUTHORIZED', async () => {
const oidcRequest = new Request('https://example.com', {
headers: { 'Oidc-Auth': 'expired-token' },
});
const oidcError = Object.assign(new Error('JWT token validation failed'), {
code: 'UNAUTHORIZED',
});
vi.mocked(validateOIDCJWT).mockRejectedValueOnce(oidcError);
await checkAuth(mockHandler)(oidcRequest, mockOptions);
expect(createErrorResponse).toHaveBeenCalledWith(ChatErrorType.Unauthorized, {
error: oidcError,
provider: 'mock',
});
expect(consoleInfoSpy).toHaveBeenCalledWith('[auth] OIDC authentication failed', {
clientId: undefined,
code: 'UNAUTHORIZED',
path: '/',
provider: 'mock',
userAgent: null,
xClientType: null,
});
expect(mockHandler).not.toHaveBeenCalled();
});
it('should return 500 when OIDC JWKS infrastructure fails (plain Error, no UNAUTHORIZED code)', async () => {
const oidcRequest = new Request('https://example.com', {
headers: { 'Oidc-Auth': 'any-token' },
});
// Simulates getVerificationKey() throwing due to misconfigured JWKS_KEY —
// a plain Error without `code: 'UNAUTHORIZED'` must bubble up as 500,
// not 401, so ops gets paged instead of the client being asked to re-auth.
const infraError = new Error('JWKS_KEY public key retrieval failed: invalid JWK');
vi.mocked(validateOIDCJWT).mockRejectedValueOnce(infraError);
await checkAuth(mockHandler)(oidcRequest, mockOptions);
expect(createErrorResponse).toHaveBeenCalledWith(ChatErrorType.InternalServerError, {
error: infraError,
provider: 'mock',
});
expect(mockHandler).not.toHaveBeenCalled();
});
it('should log decoded OIDC client info when auth fails with OIDC header', async () => {
const payload = Buffer.from(
JSON.stringify({ client_id: 'lobehub-desktop', sub: 'user-123' }),
'utf8',
).toString('base64url');
const oidcRequest = new Request('https://example.com/webapi/chat/lobehub', {
headers: {
'Oidc-Auth': `header.${payload}.signature`,
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
'x-client-type': 'desktop',
},
});
const oidcError = Object.assign(new Error('JWT token validation failed'), {
code: 'UNAUTHORIZED',
});
vi.mocked(validateOIDCJWT).mockRejectedValueOnce(oidcError);
await checkAuth(mockHandler)(oidcRequest, mockOptions);
expect(consoleInfoSpy).toHaveBeenCalledWith('[auth] OIDC authentication failed', {
clientId: 'lobehub-desktop',
code: 'UNAUTHORIZED',
path: '/webapi/chat/lobehub',
provider: 'mock',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
xClientType: 'desktop',
});
});
it('should not log OIDC auth info for better-auth session failures', async () => {
await checkAuth(mockHandler)(mockRequest, mockOptions);
expect(consoleInfoSpy).not.toHaveBeenCalled();
});
describe('mock dev user', () => {
it('should use MOCK_DEV_USER_ID when ENABLE_MOCK_DEV_USER is enabled', async () => {
vi.stubEnv('NODE_ENV', 'development');

View file

@ -23,6 +23,40 @@ export type RequestHandler = (
},
) => Promise<Response>;
interface OIDCClientDebugInfo {
clientId?: string;
payload?: Record<string, unknown>;
}
const isUnauthorizedAuthError = (error: unknown) => {
return !!error && typeof error === 'object' && 'code' in error && error.code === 'UNAUTHORIZED';
};
/**
* Decode JWT payload for debugging only.
* The decoded payload must never be trusted for authorization decisions.
*/
const getOIDCClientDebugInfo = (token?: string | null): OIDCClientDebugInfo => {
if (!token) return {};
const [, payload] = token.split('.');
if (!payload) return {};
try {
const normalizedPayload = payload.replaceAll('-', '+').replaceAll('_', '/');
const decodedPayload = JSON.parse(Buffer.from(normalizedPayload, 'base64').toString('utf8')) as
| Record<string, unknown>
| undefined;
const clientId =
typeof decodedPayload?.client_id === 'string' ? decodedPayload.client_id : undefined;
return { clientId, payload: decodedPayload };
} catch {
return {};
}
};
export const checkAuth =
(handler: RequestHandler) => async (req: Request, options: RequestOptions) => {
// Clone the request to avoid "Response body object should not be disturbed or locked" error
@ -68,11 +102,31 @@ export const checkAuth =
}
} catch (e) {
const params = await options.params;
const oidcAuthorization = req.headers.get(LOBE_CHAT_OIDC_AUTH_HEADER);
// Only log OIDC auth failures — better-auth session failures are a common
// baseline (unauthenticated browser hits) and would otherwise flood logs.
if (oidcAuthorization) {
const oidcDebugInfo = getOIDCClientDebugInfo(oidcAuthorization);
console.info('[auth] OIDC authentication failed', {
clientId: oidcDebugInfo.clientId,
code: (e as { code?: string })?.code,
path: new URL(req.url).pathname,
provider: params?.provider,
userAgent: req.headers.get('user-agent'),
xClientType: req.headers.get('x-client-type'),
});
}
// if the error is not a ChatCompletionErrorPayload, it means the application error
if (!(e as ChatCompletionErrorPayload).errorType) {
if ((e as any).code === 'ERR_JWT_EXPIRED')
return createErrorResponse(ChatErrorType.SystemTimeNotMatchError, e);
if (isUnauthorizedAuthError(e)) {
return createErrorResponse(ChatErrorType.Unauthorized, {
error: e,
provider: params?.provider,
});
}
// other issue will be internal server error
console.error(e);

View file

@ -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 }
: {}),

View file

@ -0,0 +1,60 @@
import { TRPCError } from '@trpc/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/envs/auth', () => ({
authEnv: {
JWKS_KEY: JSON.stringify({
keys: [
{
alg: 'RS256',
e: 'AQAB',
kid: 'test-key',
kty: 'RSA',
n: 'test-modulus',
use: 'sig',
},
],
}),
},
}));
const importJWKMock = vi.fn();
const jwtVerifyMock = vi.fn();
vi.mock('jose', () => ({
importJWK: (...args: unknown[]) => importJWKMock(...args),
jwtVerify: (...args: unknown[]) => jwtVerifyMock(...args),
}));
describe('validateOIDCJWT', () => {
beforeEach(() => {
vi.clearAllMocks();
importJWKMock.mockResolvedValue('public-key');
});
it('should preserve the original jose error as TRPCError cause', async () => {
const joseError = Object.assign(new Error('"exp" claim timestamp check failed'), {
code: 'ERR_JWT_EXPIRED',
});
jwtVerifyMock.mockRejectedValueOnce(joseError);
const { validateOIDCJWT } = await import('./jwt');
await expect(validateOIDCJWT('header.payload.signature')).rejects.toMatchObject({
cause: joseError,
code: 'UNAUTHORIZED',
});
});
it('should not wrap JWKS/public key retrieval failures as TRPCError', async () => {
importJWKMock.mockRejectedValueOnce(new Error('invalid JWK'));
const { validateOIDCJWT } = await import('./jwt');
const error = await validateOIDCJWT('header.payload.signature').catch((error_) => error_);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(TRPCError);
expect((error as Error).message).toBe('JWKS_KEY public key retrieval failed: invalid JWK');
});
});

View file

@ -101,22 +101,23 @@ const getVerificationKey = async () => {
* @returns Parsed token payload and user information
*/
export const validateOIDCJWT = async (token: string) => {
log('Starting OIDC JWT token validation');
// JWKS / signing key retrieval is an infrastructure concern (misconfigured
// env, malformed JWKS, key import failure). Let these errors propagate as
// plain Error so upstream middleware maps them to 500 and triggers ops
// alerts — treating them as 401 would incorrectly ask clients to re-auth
// while the real problem is server-side.
const publicKey = await getVerificationKey();
try {
log('Starting OIDC JWT token validation');
// Get public key
const publicKey = await getVerificationKey();
// Verify JWT
const { jwtVerify } = await import('jose');
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['RS256'],
// Additional validation options can be added, such as issuer, audience, etc.
});
log('JWT validation successful, payload: %O', payload);
// Extract user information
const userId = payload.sub;
const clientId = payload.client_id;
const aud = payload.aud;
@ -149,7 +150,10 @@ export const validateOIDCJWT = async (token: string) => {
log('JWT validation failed: %O', error);
// Preserve the original jose error via `cause` so upstream middleware
// can still inspect specific codes like `ERR_JWT_EXPIRED`.
throw new TRPCError({
cause: error,
code: 'UNAUTHORIZED',
message: `JWT token validation failed: ${(error as Error).message}`,
});

View file

@ -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',

View file

@ -165,6 +165,7 @@ const Content = memo<ContentProps>(({ open, searchKeyword }) => {
fav={topic.favorite}
id={topic.id}
metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId}
title={topic.title}
/>

View file

@ -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<TopicItemProps>(({ id, title, fav, active, threadId, metadata }) => {
const TopicItem = memo<TopicItemProps>(({ 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<TopicItemProps>(({ id, title, fav, active, threadId, meta
const { dropdownMenu } = useTopicItemDropdownMenu({
fav,
id,
status,
title,
});
const isCompleted = status === 'completed';
const hasUnread = id && isUnreadCompleted;
const unreadIcon = (
<span className={styles.unreadWrapper}>
@ -212,6 +216,15 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
/>
);
}
if (isCompleted) {
return (
<Icon
icon={CheckCircle2}
size={'small'}
style={{ color: cssVar.colorTextDescription }}
/>
);
}
if (hasUnread) return unreadIcon;
if (metadata?.bot?.platform) {
const ProviderIcon = getPlatformIcon(metadata.bot!.platform);

View file

@ -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: <Icon icon={isCompleted ? Circle : CheckCircle2} />,
key: 'markCompleted',
label: isCompleted ? t('actions.unmarkCompleted') : t('actions.markCompleted'),
onClick: () => {
if (isCompleted) {
unmarkTopicCompleted(id);
} else {
markTopicCompleted(id);
}
},
},
{
type: 'divider' as const,
},
{
icon: <Icon icon={Star} />,
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,

View file

@ -75,6 +75,7 @@ const GroupItem = memo<GroupItemComponentProps>(({ group, activeTopicId, activeT
id={topic.id}
key={topic.id}
metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId}
title={topic.title}
/>

View file

@ -36,6 +36,7 @@ const GroupItem = memo<GroupItemComponentProps>(({ group, activeTopicId, activeT
id={topic.id}
key={topic.id}
metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId}
title={topic.title}
/>

View file

@ -45,6 +45,7 @@ const FlatMode = memo(() => {
id={topic.id}
key={topic.id}
metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId}
title={topic.title}
/>

View file

@ -37,6 +37,7 @@ const SearchResult = memo(() => {
id={topic.id}
key={topic.id}
metadata={topic.metadata}
status={topic.status}
title={topic.title}
/>
))}

View file

@ -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<TopicProps>(({ 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 (
<AccordionItem

View file

@ -11,11 +11,14 @@ import type { TopicGroupMode, TopicSortBy } from '@/types/topic';
export const useTopicFilterDropdownMenu = (): DropdownItem[] => {
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 ? <Icon icon={LucideCheck} /> : <div />,
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]);
};

View file

@ -164,6 +164,7 @@ const Content = memo<ContentProps>(({ open, searchKeyword }) => {
active={activeTopicId === topic.id}
fav={topic.favorite}
id={topic.id}
status={topic.status}
threadId={activeThreadId}
title={topic.title}
/>

View file

@ -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<TopicItemProps>(({ id, title, fav, active, threadId }) => {
const TopicItem = memo<TopicItemProps>(({ 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<TopicItemProps>(({ 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<TopicItemProps>(({ id, title, fav, active, threadId }) =>
disabled={editing}
href={!editing ? href : undefined}
title={title === '...' ? <DotsLoading gap={3} size={4} /> : title}
icon={
isLoading ? (
<Icon spin icon={Loader2Icon} size={'small'} style={{ color: cssVar.colorWarning }} />
) : (
icon={(() => {
if (isLoading) {
return (
<Icon spin icon={Loader2Icon} size={'small'} style={{ color: cssVar.colorWarning }} />
);
}
if (isCompleted) {
return (
<Icon
icon={CheckCircle2}
size={'small'}
style={{ color: cssVar.colorTextDescription }}
/>
);
}
return (
<Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} />
)
}
);
})()}
slots={{
iconPostfix: unreadNode,
}}

View file

@ -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: <Icon icon={isCompleted ? Circle : CheckCircle2} />,
key: 'markCompleted',
label: isCompleted ? t('actions.unmarkCompleted') : t('actions.markCompleted'),
onClick: () => {
if (isCompleted) {
unmarkTopicCompleted(id);
} else {
markTopicCompleted(id);
}
},
},
{
type: 'divider' as const,
},
{
icon: <Icon icon={Wand2} />,
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,

View file

@ -42,6 +42,7 @@ const GroupItem = memo<GroupItemProps>(({ group, activeTopicId, activeThreadId }
fav={topic.favorite}
id={topic.id}
key={topic.id}
status={topic.status}
threadId={activeThreadId}
title={topic.title}
/>

View file

@ -44,6 +44,7 @@ const FlatMode = memo(() => {
fav={topic.favorite}
id={topic.id}
key={topic.id}
status={topic.status}
threadId={activeThreadId}
title={topic.title}
/>

View file

@ -36,6 +36,7 @@ const SearchResult = memo(() => {
fav={topic.favorite}
id={topic.id}
key={topic.id}
status={topic.status}
title={topic.title}
/>
))}

View file

@ -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<TopicProps>(({ 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 (
<AccordionItem

View file

@ -11,11 +11,14 @@ import type { TopicGroupMode, TopicSortBy } from '@/types/topic';
export const useTopicFilterDropdownMenu = (): DropdownItem[] => {
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 ? <Icon icon={LucideCheck} /> : <div />,
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]);
};

View file

@ -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(),
}),
}),

View file

@ -2,6 +2,11 @@ import { LobeActivatorIdentifier } from '@lobechat/builtin-tool-activator';
import { AgentBuilderIdentifier } from '@lobechat/builtin-tool-agent-builder';
import { AgentManagementIdentifier } from '@lobechat/builtin-tool-agent-management';
import { CredsIdentifier, type CredSummary, generateCredsList } from '@lobechat/builtin-tool-creds';
import {
CronIdentifier,
type CronJobSummaryForContext,
generateCronJobsList,
} from '@lobechat/builtin-tool-cron';
import { GroupAgentBuilderIdentifier } from '@lobechat/builtin-tool-group-agent-builder';
import { GTDIdentifier } from '@lobechat/builtin-tool-gtd';
import { WebOnboardingIdentifier } from '@lobechat/builtin-tool-web-onboarding';
@ -377,6 +382,42 @@ export const contextEngineering = async ({
}
}
// Resolve cron jobs context for cron tool
// Only inject a small preview (up to 4) to save context window;
// the model can call listCronJobs API for the full list.
const isCronEnabled = tools?.includes(CronIdentifier) ?? false;
let cronJobsList: CronJobSummaryForContext[] | undefined;
let cronJobsTotal = 0;
if (isCronEnabled && agentId) {
try {
const cronResult = await lambdaClient.agentCronJob.list.query({ agentId, limit: 4 });
const jobs = (cronResult as any)?.data ?? [];
cronJobsTotal = (cronResult as any)?.pagination?.total ?? jobs.length;
cronJobsList = jobs.map(
(job: any): CronJobSummaryForContext => ({
cronPattern: job.cronPattern,
description: job.description,
enabled: job.enabled,
id: job.id,
lastExecutedAt: job.lastExecutedAt,
name: job.name,
remainingExecutions: job.remainingExecutions,
timezone: job.timezone ?? 'UTC',
totalExecutions: job.totalExecutions ?? 0,
}),
);
log(
'Cron jobs context resolved: count=%d, total=%d',
cronJobsList?.length ?? 0,
cronJobsTotal,
);
} catch (error) {
// Silently fail - cron context is optional
log('Failed to resolve cron jobs context:', error);
}
}
const userMemoryConfig =
enableUserMemories && userMemoryData
? {
@ -694,6 +735,8 @@ export const contextEngineering = async ({
...VARIABLE_GENERATORS,
// NOTICE: required by builtin-tool-creds/src/systemRole.ts
CREDS_LIST: () => (credsList ? generateCredsList(credsList) : ''),
// NOTICE: required by builtin-tool-cron/src/systemRole.ts
CRON_JOBS_LIST: () => (cronJobsList ? generateCronJobsList(cronJobsList, cronJobsTotal) : ''),
// NOTICE(@nekomeowww): required by builtin-tool-memory/src/systemRole.ts
memory_effort: () => (userMemoryConfig ? (memoryContext?.effort ?? '') : ''),
// Current agent + topic identity — referenced by the LobeHub builtin

View file

@ -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;
};

View file

@ -234,6 +234,20 @@ export class ChatTopicActionImpl {
});
};
markTopicCompleted = async (id: string): Promise<void> => {
await this.#get().internal_updateTopic(id, {
completedAt: new Date(),
status: 'completed',
});
};
unmarkTopicCompleted = async (id: string): Promise<void> => {
await this.#get().internal_updateTopic(id, {
completedAt: null,
status: 'active',
});
};
favoriteTopic = async (id: string, favorite: boolean): Promise<void> => {
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,

View file

@ -5,6 +5,7 @@ import { type ChatTopic } from '@/types/topic';
*/
export interface TopicData {
currentPage: number;
excludeStatuses?: string[];
excludeTriggers?: string[];
hasMore: boolean;
isExpandingPageSize?: boolean;

View file

@ -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,
};