mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
Compare commits
7 commits
v2.1.53-ca
...
canary
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0db58e622 | ||
|
|
61224fe76c | ||
|
|
8119789849 | ||
|
|
1ffd01a9eb | ||
|
|
9d3696ceef | ||
|
|
595193ce62 | ||
|
|
665b482390 |
43 changed files with 817 additions and 76 deletions
82
.github/workflows/release-model-bank.yml
vendored
82
.github/workflows/release-model-bank.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "按更新时间",
|
||||
|
|
|
|||
25
packages/builtin-tool-cron/src/helpers.ts
Normal file
25
packages/builtin-tool-cron/src/helpers.ts
Normal 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');
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { CronExecutionRuntime, type ICronService } from './ExecutionRuntime';
|
||||
export { generateCronJobsList } from './helpers';
|
||||
export { CronIdentifier, CronManifest } from './manifest';
|
||||
export { systemPrompt } from './systemRole';
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const DEFAULT_PREFERENCE: UserPreference = {
|
|||
enableInputMarkdown: true,
|
||||
},
|
||||
topicGroupMode: 'byTime',
|
||||
topicIncludeCompleted: false,
|
||||
topicSortBy: 'updatedAt',
|
||||
useCmdEnterToSend: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
|
|
|
|||
60
src/libs/oidc-provider/jwt.test.ts
Normal file
60
src/libs/oidc-provider/jwt.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const FlatMode = memo(() => {
|
|||
id={topic.id}
|
||||
key={topic.id}
|
||||
metadata={topic.metadata}
|
||||
status={topic.status}
|
||||
threadId={activeThreadId}
|
||||
title={topic.title}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const SearchResult = memo(() => {
|
|||
id={topic.id}
|
||||
key={topic.id}
|
||||
metadata={topic.metadata}
|
||||
status={topic.status}
|
||||
title={topic.title}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const FlatMode = memo(() => {
|
|||
fav={topic.favorite}
|
||||
id={topic.id}
|
||||
key={topic.id}
|
||||
status={topic.status}
|
||||
threadId={activeThreadId}
|
||||
title={topic.title}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const SearchResult = memo(() => {
|
|||
fav={topic.favorite}
|
||||
id={topic.id}
|
||||
key={topic.id}
|
||||
status={topic.status}
|
||||
title={topic.title}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { type ChatTopic } from '@/types/topic';
|
|||
*/
|
||||
export interface TopicData {
|
||||
currentPage: number;
|
||||
excludeStatuses?: string[];
|
||||
excludeTriggers?: string[];
|
||||
hasMore: boolean;
|
||||
isExpandingPageSize?: boolean;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue