feat(task-template): add home recommendation system with skill connect (#14214)

This commit is contained in:
YuTengjing
2026-04-28 18:11:00 +08:00
committed by GitHub
parent 2654c4d31e
commit 19643ba662
30 changed files with 2706 additions and 111 deletions
+11
View File
@@ -30,6 +30,17 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
## Language
Issue titles, descriptions, and comments **MUST follow the language of the current conversation**, not default to English.
- Conversation in 中文 → issue body in 中文;technical terms (file paths, identifiers, library names, commands, error messages) stay in English.
- Conversation in English → issue body in English.
- Code blocks, file paths, and quoted strings always stay in their original form regardless of surrounding language.
- This applies equally to **updates** — when editing an existing issue (description **and titles**), preserve the language of the conversation that triggered the edit; do not switch the issue language during a refactor (Chinese → English or vice versa).
Rationale: the issue is a continuation of the conversation. Forcing English when the discussion is in Chinese creates translation friction for the collaborator who came from that thread.
## Creating Sub-issue Trees
When breaking a parent issue into a tree of sub-issues (e.g., task decomposition for LOBE-xxx), follow these rules — they work around real limitations of the Linear MCP tools.
+1
View File
@@ -1943,6 +1943,7 @@ table user_memory_persona_documents {
}
}
ref: agent_skills.user_id - users.id
ref: agent_skills.zip_file_hash - global_files.hash_id
+69
View File
@@ -0,0 +1,69 @@
{
"action.connect.button": "Connect {{provider}}",
"action.create.error": "Failed to create task. Please try again.",
"action.create.success": "Scheduled task added. Find it in Lobe AI.",
"action.createButton": "Add as scheduled task",
"action.creating": "Creating...",
"action.dismiss.error": "Failed to dismiss. Please try again.",
"action.dismiss.tooltip": "Not interested",
"arxiv-curated-daily.description": "Every morning, pick 5 fresh papers in your research area with one-line summaries. Cut your paper-scanning time in half.",
"arxiv-curated-daily.prompt": "Every morning at 9:00, pick 5 of the latest arXiv papers in my research area and give me a one-line summary for each, so I can decide which to read in depth.",
"arxiv-curated-daily.title": "ArXiv daily picks",
"competitor-radar-weekly.description": "Every Monday, scan your top competitors for product launches, pricing changes, hiring signals, and press mentions.",
"competitor-radar-weekly.prompt": "Every Monday at 10:00, scan my top competitors for the past week — product launches, pricing changes, hiring signals, press mentions — and summarize what each move implies strategically.",
"competitor-radar-weekly.title": "Competitor radar weekly",
"daily-design-inspiration.description": "Each morning, curate 10 works from Dribbble, Behance, Awwwards and Pinterest that match your style.",
"daily-design-inspiration.prompt": "Every morning at 9:00, curate 10 design works from Dribbble, Behance, Awwwards, and Pinterest that match my style, with a short note on what makes each one stand out.",
"daily-design-inspiration.title": "Daily design inspiration",
"daily-learning-bite.description": "Each morning, deliver one 15-minute curated piece (article, video, or podcast) in your learning area.",
"daily-learning-bite.prompt": "Every morning at 7:30, bring me one 15-minute curated piece (article, video, or podcast) in my learning area, with a quick takeaway.",
"daily-learning-bite.title": "Daily learning bite",
"daily-topic-pick.description": "Each morning, scan the top 10 pieces that performed best in your niche yesterday and break down the angles.",
"daily-topic-pick.prompt": "Every morning at 9:00, gather the 10 best-performing pieces of content from my niche yesterday, break down their angles, and pick 1-2 I could publish today.",
"daily-topic-pick.title": "Daily topic radar",
"feature-ideation-friday.description": "Every Friday afternoon, generate 5 feature ideas based on this week's user feedback and competitor moves.",
"feature-ideation-friday.prompt": "Every Friday at 15:00, synthesize this week's user feedback and competitor activity into 5 concrete feature ideas, each with a one-line user-value statement and a rough effort tag (S/M/L).",
"feature-ideation-friday.title": "Feature ideation Friday",
"font-of-the-week.description": "Each Wednesday, one handpicked typeface with use cases, pairing suggestions, and where to get it.",
"font-of-the-week.prompt": "Every Wednesday at 9:00, pick one noteworthy typeface, explain its best use cases, suggest 2 pairings, and list where designers can license or download it.",
"font-of-the-week.title": "Font of the week",
"frontend-weekly-digest.description": "Every Monday, a curated digest of frontend news: browser updates, framework releases, notable blog posts.",
"frontend-weekly-digest.prompt": "Every Monday at 9:00, curate a frontend weekly digest: browser engine updates, framework releases (React, Vue, Svelte, etc.), notable engineering blog posts — 10 items with a one-line takeaway each.",
"frontend-weekly-digest.title": "Frontend weekly digest",
"github-pr-review-daily.description": "Each morning, list the PRs awaiting your review across your GitHub repos with a one-line takeaway each.",
"github-pr-review-daily.prompt": "Every morning at 9:00, fetch open PRs across my GitHub repos that are waiting on my review. For each, summarize the change in one line and flag anything that looks risky or has been sitting for more than 2 days.",
"github-pr-review-daily.title": "GitHub PR review queue",
"hn-writing-angles.description": "Each morning, mine Hacker News front-page discussions and pull out 5 angles you could write about today.",
"hn-writing-angles.prompt": "Every morning at 10:00, scan today's Hacker News front page and comments, surface 5 writable angles relevant to my niche, and note why each one has momentum right now.",
"hn-writing-angles.title": "HN writing angles",
"industry-morning-brief.description": "Each morning, condense 5 important news items, funding rounds and policy shifts in your industry into a 5-minute read.",
"industry-morning-brief.prompt": "Every morning at 8:00, condense 5 important news items, funding rounds, and policy shifts from my industry into a 5-minute read.",
"industry-morning-brief.title": "Industry morning brief",
"leetcode-daily.description": "One LeetCode problem every evening with a reference solution and two alternative approaches.",
"leetcode-daily.prompt": "Every evening at 19:00, pick one LeetCode problem at an appropriate difficulty, show the reference solution, and explain two alternative approaches with tradeoffs.",
"leetcode-daily.title": "LeetCode daily drill",
"marketing-hot-radar.description": "Each morning, track 5 marketing topics heating up in your industry — which to ride, which to avoid.",
"marketing-hot-radar.prompt": "Every morning at 10:00, track 5 marketing topics heating up in my industry, flag which ones to ride and which to avoid, with 1-2 sentence reasoning.",
"marketing-hot-radar.title": "Marketing hot radar",
"notion-weekly-digest.description": "Every Monday, summarize last week's edits and new pages in your Notion workspace, grouped by area.",
"notion-weekly-digest.prompt": "Every Monday at 9:00, scan my Notion workspace for pages edited or created in the last 7 days. Group by top-level area and pick the 5 most important changes worth re-reading.",
"notion-weekly-digest.title": "Notion weekly digest",
"oss-intel-daily.description": "Each morning, get 10 tech stack updates: GitHub Trending, big-name open-sourcing, key repo releases.",
"oss-intel-daily.prompt": "Every morning at 9:00, give me 10 tech-stack updates: GitHub Trending, notable open-source releases from big companies, and new releases from repos in my stack.",
"oss-intel-daily.title": "Open-source intel daily",
"sales-pipeline-review.description": "Every Friday, review your pipeline: stalled deals, upcoming renewals, and 3 high-priority follow-ups for next week.",
"sales-pipeline-review.prompt": "Every Friday at 17:00, review my sales pipeline: list deals stalled for over 7 days, upcoming renewals in the next 30 days, and suggest 3 high-priority follow-ups for next Monday with talking points.",
"sales-pipeline-review.title": "Sales pipeline review",
"schedule.daily": "Every day at {{time}}",
"schedule.weekly": "Every {{weekday}} at {{time}}",
"section.title": "Try these scheduled tasks",
"seo-weekly-report.description": "Every Monday, a lightweight SEO report: ranking movement, new keywords to chase, and pages worth refreshing.",
"seo-weekly-report.prompt": "Every Monday at 9:00, give me a lightweight SEO weekly: top ranking movers (up/down), 5 emerging keywords worth targeting, and 3 existing pages ripe for a content refresh.",
"seo-weekly-report.title": "SEO weekly report",
"user-feedback-daily.description": "Each morning, aggregate feedback from all channels (stores, social, support) into top 20 items, sorted by sentiment and theme.",
"user-feedback-daily.prompt": "Every morning at 9:00, aggregate user feedback from all channels (app stores, social media, customer support) into the top 20 items, sorted by sentiment and theme.",
"user-feedback-daily.title": "User feedback daily",
"weekly-engineering-digest.description": "Every Friday afternoon, cross-reference your GitHub PR activity and Linear sprint progress into a single end-of-week status.",
"weekly-engineering-digest.prompt": "Every Friday at 17:00, combine my GitHub PR activity (merged, reviewed, opened) with my Linear sprint status (issues closed, in progress, blockers) into one end-of-week status. Highlight 3 things shipped and 1-2 risks for next week.",
"weekly-engineering-digest.title": "Weekly engineering digest"
}
+9
View File
@@ -87,10 +87,19 @@
"finish": "开始使用",
"interests.area.business": "商业与战略",
"interests.area.coding": "编程与开发",
"interests.area.creator": "创作者经济",
"interests.area.design": "设计与创意",
"interests.area.education": "学习与研究",
"interests.area.finance-legal": "财务与法务",
"interests.area.health": "健康与习惯",
"interests.area.hobbies": "兴趣与探索",
"interests.area.hr": "人力资源",
"interests.area.investing": "投资与理财",
"interests.area.marketing": "市场与推广",
"interests.area.operations": "运营与行政",
"interests.area.other": "其他领域",
"interests.area.parenting": "家庭与育儿",
"interests.area.personal": "个人生活",
"interests.area.product": "产品与管理",
"interests.area.sales": "销售与客户",
"interests.area.writing": "内容创作",
+349
View File
@@ -0,0 +1,349 @@
{
"action.connect.button": "连接 {{provider}}",
"action.create.error": "创建任务失败,请稍后再试",
"action.create.success": "定时任务已创建,可在 Lobe AI 中查看",
"action.createButton": "添加为定时任务",
"action.creating": "创建中…",
"action.dismiss.error": "操作失败,请稍后再试",
"action.dismiss.tooltip": "不感兴趣",
"action.optionalConnect.button": "连接 {{provider}} 获取更丰富的内容",
"ad-creative-inspiration.description": "每天扫一遍竞品和标杆品牌的新广告素材(Meta / Google Ads Library),挖能复刻的 10 条",
"ad-creative-inspiration.prompt": "每天早上 10:00 扫一遍我的竞品和标杆品牌在 Meta 和 Google Ads Library 上的新素材,挑出 10 条值得复刻的,并说明每条值得的理由。",
"ad-creative-inspiration.title": "投放素材灵感",
"aigc-prompt-inspiration.description": "每天 5 组精选 PromptMidjourney / SD / Flux),按风格分类,今天就能试",
"aigc-prompt-inspiration.prompt": "每天早上 10:00 给我 5 组精选 PromptMidjourney / Stable Diffusion / Flux),按风格分类,每条都要可以直接复制使用。",
"aigc-prompt-inspiration.title": "AIGC Prompt 灵感",
"arxiv-curated-daily.description": "每天早上帮你筛 5 篇最新论文 + 一句话摘要,刷论文时间省一半",
"arxiv-curated-daily.prompt": "每天早上 9:00 帮我从 arXiv 筛 5 篇和我研究方向相关的最新论文,每篇附一句话摘要,帮我判断哪些值得精读。",
"arxiv-curated-daily.title": "ArXiv 精选",
"bedtime-gratitude.description": "每天 22 点引导你写下今天三件感谢的事 + 学到的一件事,沉淀进笔记",
"bedtime-gratitude.prompt": "每天晚上 22:00 引导我写下今天三件感谢的事和学到的一件事,回我一段温柔的小结。如果连接了 Notion,把这条感恩记录追加到我的日记页。",
"bedtime-gratitude.title": "睡前感恩",
"brand-collab-weekly.description": "每周一扫一遍正在找创作者的品牌和公开招募,匹配你的领域和粉丝规模",
"brand-collab-weekly.prompt": "每周一早上 10:00 扫一遍正在公开招募创作者的品牌,按我的赛道和粉丝量匹配,挑出 5 个值得申请的机会。",
"brand-collab-weekly.title": "品牌合作机会",
"brand-mention-daily.description": "告诉我要追踪的品牌 / 关键词,每天傍晚汇总当天提及量、情绪、热门发言者",
"brand-mention-daily.prompt": "每天傍晚 18:00 在 X (Twitter) 上汇总我追踪的品牌和关键词当天的提及量、情绪、TOP 发言者,标出异常波动。",
"brand-mention-daily.title": "品牌声量日报",
"brand-watch-weekly.description": "每周一追踪 10 家大厂品牌升级、Logo 改版、官网重设计,附拆解视角",
"brand-watch-weekly.prompt": "每周一早上 10:00 追踪 10 家我关注的品牌动态:品牌升级、Logo 改版、官网重设计,每条附一段拆解。",
"brand-watch-weekly.title": "大厂品牌动态",
"calendar-conflict-check.description": "每天早上检查日程有没有冲突、间隔太紧、通勤不够",
"calendar-conflict-check.prompt": "每天早上 7:30 检查我今天的日历,找出冲突、连背靠背会议、通勤或缓冲时间不够的情况,给出建议调整方案。",
"calendar-conflict-check.title": "日历冲突检查",
"cashflow-weekly.description": "每周一扫一下本周该收的款、该付的账、下周大额支出预警",
"cashflow-weekly.prompt": "每周一早上 9:00 扫一遍本周应收款、应付账,并预警下周的大额支出。",
"cashflow-weekly.title": "现金流周报",
"child-growth-weekly.description": "告诉我孩子年龄,每周一给你本周发育重点、亲子活动建议、注意事项",
"child-growth-weekly.prompt": "每周一早上 9:00 根据我孩子的年龄,给出本周的发育重点、亲子活动建议和需要留心的注意事项。",
"child-growth-weekly.title": "孩子成长周报",
"child-study-weekly.description": "告诉我孩子在学的科目,每周日帮你回顾本周完成情况 + 下周重点",
"child-study-weekly.prompt": "每周日晚 20:00 回顾我孩子本周的学习进度,并梳理下周的学习重点,按科目给出练习建议。",
"child-study-weekly.title": "孩子学习追踪",
"competitor-creator-tracking.description": "告诉我 3-5 个你关注的创作者,每天看他们发了什么、数据怎样,挖能复刻的思路",
"competitor-creator-tracking.prompt": "每天早上 9:00 追踪我设定的 3-5 个对标创作者:他们发了什么内容、数据如何,挖出我可以复刻的思路。",
"competitor-creator-tracking.title": "竞品创作者追踪",
"competitor-radar-daily.description": "告诉我 3-5 个竞争对手,每天帮你盯他们的官网更新、产品发布、招聘信号、社媒动态",
"competitor-radar-daily.prompt": "每天早上 9:00 追踪我设定的 3-5 个竞争对手:官网更新、产品发布、招聘信号、社媒动态,分析每个动作的战略含义。",
"competitor-radar-daily.title": "竞品动态追踪",
"competitor-update-daily.description": "告诉我 3-5 个竞品,每天看他们的更新日志、新功能、官网变化",
"competitor-update-daily.prompt": "每天早上 10:00 监测 3-5 个竞品产品的更新:更新日志、新功能、官网文案变化,标出值得深入研究的信号。",
"competitor-update-daily.title": "竞品更新追踪",
"content-calendar-weekly.description": "每周日晚帮你规划下周 7 天的发布计划,对齐节日和热点节奏",
"content-calendar-weekly.prompt": "每周日晚 20:00 帮我规划下周 7 天的发布计划:把日程对齐近期节日和热点节奏,每个时段给一个建议选题。如果连接了 Notion,把这份计划同步成排期表。",
"content-calendar-weekly.title": "内容日历",
"contract-expiry-weekly.description": "每周一检查下个月到期的合同(订阅 / 租赁 / 合作),提前续签或解约",
"contract-expiry-weekly.prompt": "每周一早上 9:00 列出未来 30 天到期的合同(订阅、租赁、合作),标注哪些需要续签、哪些可以解约。",
"contract-expiry-weekly.title": "合同到期预警",
"core-metric-daily.description": "告诉我要看的指标(DAU、留存、转化),每天早上自动同步变化",
"core-metric-daily.prompt": "每天早上 9:00 同步我的核心指标变化(DAU、留存、转化),与昨天和 7 日均值做对比。",
"core-metric-daily.title": "核心指标日报",
"cross-platform-engagement-daily.description": "每天早上聚合你全平台的评论、私信、提及、新粉丝",
"cross-platform-engagement-daily.prompt": "每天早上 9:00 聚合我各平台的评论、私信、提及和新粉丝,标出 5 条最值得回复的。",
"cross-platform-engagement-daily.title": "全平台互动日报",
"crypto-market-daily.description": "每天早上看比特币、以太坊、你关注币种的 24h 变化 + 重要链上事件",
"crypto-market-daily.prompt": "每天早上 9:00 给我比特币、以太坊和我关注币种的 24h 价格变化,加上过去一天最重要的链上事件。",
"crypto-market-daily.title": "加密市场日报",
"daily-design-inspiration.description": "每天早上从 Dribbble、Behance、Awwwards、Pinterest 挑 10 个和你风格匹配的作品",
"daily-design-inspiration.prompt": "每天早上 9:00 从 Dribbble、Behance、Awwwards、Pinterest 挑 10 个和我风格匹配的作品,各附一句亮点点评。",
"daily-design-inspiration.title": "每日灵感",
"daily-followup-list.description": "每天早上按优先级排一遍今天该跟进的客户,附上次沟通要点",
"daily-followup-list.prompt": "每天早上 9:00 从 HubSpot 中排出今天该跟进的客户优先级清单,每条附上次沟通要点。",
"daily-followup-list.title": "今日跟进清单",
"daily-learning-bite.description": "每天给你一条 15 分钟能看完的学习内容(文章 / 视频 / 播客)",
"daily-learning-bite.prompt": "每天早上 7:30 给我一条 15 分钟能看完的学习内容(文章 / 视频 / 播客),附一句关键收获。",
"daily-learning-bite.title": "每日学习料",
"daily-topic-pick.description": "每天早上帮你扒一遍你赛道前一天跑得最好的 10 条内容,拆解选题角度",
"daily-topic-pick.prompt": "每天早上 9:00 帮我扒我赛道前一天跑得最好的 10 条内容,拆解选题角度,挑 1-2 个我今天能直接发的。",
"daily-topic-pick.title": "今日选题",
"deal-pipeline-weekly.description": "每周五盘点管道里所有 Deal:哪些推进、哪些停滞、本月能成多少",
"deal-pipeline-weekly.prompt": "每周五下午 16:00 盘点 HubSpot 管道里的所有 Deal:本周推进的、停滞的,并预测本月能成多少。",
"deal-pipeline-weekly.title": "Deal Pipeline 周报",
"dependency-security-weekly.description": "每周一自动扫一遍你项目的漏洞和过期版本,给出升级优先级",
"dependency-security-weekly.prompt": "每周一早上 10:00 扫一遍我 GitHub 项目里有漏洞或过期的依赖,按严重程度和升级风险排出优先级。",
"dependency-security-weekly.title": "依赖安全周检",
"design-trend-weekly.description": "每周一给你本周 UI / 品牌 / 插画 3 个新趋势 + 5 个代表案例",
"design-trend-weekly.prompt": "每周一早上 9:00 给我 UI、品牌、插画领域本周 3 个新趋势,每个趋势配 5 个代表案例。",
"design-trend-weekly.title": "设计趋势周报",
"diet-log-companion.description": "每天晚上帮你回顾今天吃了什么,给下次的调整建议。不强迫、不批判",
"diet-log-companion.prompt": "每天晚上 21:00 陪我回顾今天的饮食,温柔地给一两条明天可以调整的建议,不强迫、不批判。",
"diet-log-companion.title": "饮食记录陪跑",
"exhibition-event-weekly.description": "告诉我你所在城市,每周一给你本周的展览、演出、livehouse 信息",
"exhibition-event-weekly.prompt": "每周一早上 10:00 列出我所在城市本周的展览、演出、livehouse 演出信息,给出最值得去的几条简介。",
"exhibition-event-weekly.title": "展览演出日历",
"family-finance-weekly.description": "每周日晚回顾本周支出结构、预算完成度、下周大额计划",
"family-finance-weekly.prompt": "每周日晚 20:00 基于 Google Sheets 流水回顾本周家庭支出结构、预算完成度,并预告下周的大额支出计划。",
"family-finance-weekly.title": "家庭财务周报",
"family-task-schedule.description": "每周一早上分配本周家务、采购、接送、缴费,家庭群可同步",
"family-task-schedule.prompt": "每周一早上 8:00 帮我排好本周家庭任务计划:家务、采购、接送、缴费,给每项一个暂定责任人和时间段。如果连接了 Google Calendar,建议可直接落进日程的时间块。",
"family-task-schedule.title": "家庭任务排期",
"figma-files-cleanup.description": "每周五下班前帮你盘点近期更新的 Figma 文件,标记该归档的、该同步开发的",
"figma-files-cleanup.prompt": "每周五下午 17:00 盘点近期更新的 Figma 文件,标记哪些该归档、哪些需要同步给开发,还有哪些需要继续打磨。",
"figma-files-cleanup.title": "Figma 文件整理",
"follower-growth-weekly.description": "每周一看跨平台的粉丝变化:哪个平台在涨、哪个在跌、该加码哪里",
"follower-growth-weekly.prompt": "每周一早上 10:00 回顾我 X (Twitter) 等平台的粉丝增长,标出该加码的平台和互动下滑的平台。",
"follower-growth-weekly.title": "粉丝增长周报",
"font-color-weekly.description": "每周三给你 3 组值得收藏的字体组合 + 3 组配色方案,直接存进灵感库",
"font-color-weekly.prompt": "每周三早上 10:00 给我 3 组值得收藏的字体组合和 3 组配色方案,每组字体附授权或下载渠道。",
"font-color-weekly.title": "字体配色周报",
"friday-wrap-list.description": "每周五下午列一份:这周没做完的、周一要交付的、下周第一件事",
"friday-wrap-list.prompt": "每周五下午 16:00 列一份:本周没做完的、周一要交付的、下周第一件事。",
"friday-wrap-list.title": "周五收尾清单",
"funding-intel-daily.description": "每天给你 3-5 条你赛道的融资快讯:谁拿钱、估值多少、投资人是谁",
"funding-intel-daily.prompt": "每天早上 10:00 给我赛道里过去 24 小时的 3-5 条融资快讯:谁拿了钱、金额、估值、领投方。",
"funding-intel-daily.title": "融资情报日报",
"headline-inspiration.description": "每天给你 10 个符合你调性的标题模板,从近期爆款反推的结构",
"headline-inspiration.prompt": "每天早上 10:00 从近期爆款反推 10 个符合我调性的标题模板,卡标题时直接抄。",
"headline-inspiration.title": "标题灵感",
"hot-topic-radar.description": "每天早上一次性看完你领域里正在升温的 5 个话题,趁还没挤满就能下手",
"hot-topic-radar.prompt": "每天早上 10:00 给我我赛道里正在升温但还没饱和的 5 个话题,每条说明现在为什么值得下手。",
"hot-topic-radar.title": "热点雷达",
"hubspot-funnel-daily.description": "每天早上看 MQL、SQL、成交漏斗变化,标出掉单高发环节",
"hubspot-funnel-daily.prompt": "每天早上 9:00 看 HubSpot 漏斗:MQL、SQL、成交各阶段的变化,对比上周标出掉单高发的环节。",
"hubspot-funnel-daily.title": "HubSpot 漏斗日报",
"industry-morning-brief.description": "每天早上把你行业 5 条重要新闻、融资、政策变化做成 5 分钟读物",
"industry-morning-brief.prompt": "每天早上 8:00 把我行业 5 条重要新闻、融资、政策变化汇总成 5 分钟读物。",
"industry-morning-brief.title": "行业早餐",
"industry-research-weekly.description": "告诉我你研究的赛道,每周一给你一份市场动态、融资、新玩家、监管变化汇总",
"industry-research-weekly.prompt": "每周一早上 9:00 汇总我赛道的市场动态、融资、新玩家、监管变化,整理成研究简报。",
"industry-research-weekly.title": "行业研究周报",
"invoice-collection-daily.description": "每天早上看哪些发票逾期了、逾期多少天、该发催款邮件了",
"invoice-collection-daily.prompt": "每天早上 10:00 列出逾期发票和对接联系人,并为每条草拟一封礼貌的催款邮件。",
"invoice-collection-daily.title": "发票催收日报",
"iteration-recap-weekly.description": "每周五下班前帮你拉本周迭代数据:完成率、逾期项、新增 Bug",
"iteration-recap-weekly.prompt": "每周五下午 17:00 复盘本周迭代:完成率、逾期项、新增 Bug,整理成下周一可直接用的复盘材料。",
"iteration-recap-weekly.title": "迭代复盘周报",
"key-account-radar.description": "告诉我核心客户的公司名,每天盯他们的新闻、融资、高管变动",
"key-account-radar.prompt": "每天早上 9:00 扫一遍我核心客户的公司新闻、融资、高管变动,挑出可作为续约谈话切入点的素材。",
"key-account-radar.title": "客户动态雷达",
"keyword-tech-feed.description": "告诉我你想追踪的技术关键词,每天带回 5 条高质量新问答 / 新博客",
"keyword-tech-feed.prompt": "每天早上 10:00 按我设定的技术关键词带回 5 条高质量的新博客或新问答。",
"keyword-tech-feed.title": "关键词技术订阅",
"kol-collab-calendar.description": "每周一同步正在合作的 KOL 进度:谁该发了、谁逾期、数据怎样",
"kol-collab-calendar.prompt": "每周一早上 9:00 同步正在进行的 KOL 合作:谁该发了、谁逾期、已发出内容的数据。",
"kol-collab-calendar.title": "KOL 合作日历",
"language-morning-bite.description": "每天给你一段 3 分钟能读完的目标语言内容 + 5 个生词卡",
"language-morning-bite.prompt": "每天早上 7:30 给我一段 3 分钟能读完的目标语言内容,加上 5 个生词卡(单词、释义、例句)。",
"language-morning-bite.title": "语言早报",
"linear-sprint-daily.description": "每天早上同步 Sprint 进度:哪些卡住了、哪些逾期、今天该做什么",
"linear-sprint-daily.prompt": "每天早上 8:30 从 Linear 拉一份 Sprint 进度:今日重点、阻塞项、昨日完成,整理成 3 条站会前可直接念的要点。",
"linear-sprint-daily.title": "Linear Sprint 日报",
"macro-economy-weekly.description": "每周一早给你汇率、利率、原油、金银、主要指数汇总",
"macro-economy-weekly.prompt": "每周一早上 8:00 给我宏观快照:汇率、利率、原油、金银、主要指数,加上一段「本周变化」总结。",
"macro-economy-weekly.title": "宏观经济周报",
"marketing-hot-radar.description": "每天追踪你行业正在发酵的 5 个营销话题:哪些值得蹭、哪些要避雷",
"marketing-hot-radar.prompt": "每天早上 10:00 追踪我行业正在发酵的 5 个营销话题,标注哪些值得蹭、哪些要避雷,各附 1-2 句理由。",
"marketing-hot-radar.title": "营销热点雷达",
"meeting-brief.description": "每天早上把今天所有会议的背景、参会人、上次纪要整理成 1 页",
"meeting-brief.prompt": "每天早上 8:30 为今天日历上的每个会议生成一页简报:背景、参会人、上次纪要,进会议室前看一眼。",
"meeting-brief.title": "会议简报",
"monetization-opportunity-weekly.description": "每周三给你内容创作者的新变现渠道和案例:广告 / 知识付费 / 会员 / 电商",
"monetization-opportunity-weekly.prompt": "每周三早上 10:00 给我我赛道相关的新变现渠道和案例:广告、知识付费、会员订阅、电商。",
"monetization-opportunity-weekly.title": "变现机会播报",
"morning-brief.description": "每天 8 点推一份:今天日程、待回邮件数、待办清单、天气",
"morning-brief.prompt": "每天早上 8:00 推送:今天日程、待回邮件数、TOP 3 待办、天气,整理成 1 分钟能读完的早报。",
"morning-brief.title": "晨间早报",
"morning-ritual.description": "每天 7 点:天气 + 今日日程 + 一条金句 + 一个动一动建议,温柔开启一天",
"morning-ritual.prompt": "每天早上 7:00 给我一份温柔的晨间仪式:天气、今日日程、一条短金句、一个轻量运动建议。如果连接了 Google Calendar,把日程锚定在那里。",
"morning-ritual.title": "晨间仪式",
"must-read-papers-weekly.description": "每周日晚帮你挑本周被引最多、讨论最热的 3 篇论文,做成精读清单",
"must-read-papers-weekly.prompt": "每周日晚 20:00 帮我挑本周我研究方向被引最多或讨论最热的 3 篇论文,整理成周末可读完的精读清单。",
"must-read-papers-weekly.title": "本周必读论文",
"newsletter-aggregator.description": "每周日晚把你订阅的 Newsletter 合并成一份摘要,周末一次性读完",
"newsletter-aggregator.prompt": "每周日晚 20:00 扫一遍我 Gmail 收件箱里本周收到的 Newsletter,按主题合并成一份周末摘要。",
"newsletter-aggregator.title": "Newsletter 聚合",
"newsletter-perf-weekly.description": "每周一帮你看 Newsletter 的打开率、点击率、取关率变化趋势,标出需要优化的环节",
"newsletter-perf-weekly.prompt": "每周一早上 10:00 回顾过去 4 周 Newsletter 的打开率、点击率、取关率,标出需要优化的人群分层。",
"newsletter-perf-weekly.title": "Newsletter 表现周报",
"onboarding-buddy-weekly.description": "新人入职 90 天内,每周一生成他的进度:任务完成、Buddy 反馈、该关注什么",
"onboarding-buddy-weekly.prompt": "每周一早上 9:00 为还在 90 天试用期内的新人生成进度卡:任务完成度、Buddy 反馈、本周该关注什么。",
"onboarding-buddy-weekly.title": "新人入职陪跑",
"oss-intel-daily.description": "每天早上给你 10 条技术栈动态:GitHub Trending、大厂新开源、关键 repo 的 release",
"oss-intel-daily.prompt": "每天早上 9:00 给我 10 条技术栈动态:GitHub Trending、大厂新开源、我关注的 repo 新 release。",
"oss-intel-daily.title": "开源情报日报",
"podcast-new-episodes.description": "告诉我你订阅的播客,每周一给你本周新集 + 值得听的 3 集推荐",
"podcast-new-episodes.prompt": "每周一早上 9:00 列出我订阅播客本周的新集,并推荐其中最值得先听的 3 集。",
"podcast-new-episodes.title": "播客新集聚合",
"portfolio-daily.description": "告诉我你的持仓股票 / 基金 / 加密货币,每天收盘后给你涨跌、重要新闻、持仓公司动态",
"portfolio-daily.prompt": "每天 16:00 收盘后给我每个持仓的当日涨跌、影响新闻和公司公告。",
"portfolio-daily.title": "持仓日报",
"prd-review-reminder.description": "每周五盘点本周该评审的 PRD 和决策项,别让文档压在草稿箱",
"prd-review-reminder.prompt": "每周五下午 15:00 盘点我 Notion 里本周该评审的 PRD 和决策文档,标出仍卡在草稿状态的。",
"prd-review-reminder.title": "PRD 评审提醒",
"pre-market-brief.description": "每天开盘前 30 分钟给你宏观要闻、重要财报、你持仓公司的动态",
"pre-market-brief.prompt": "每天早上 9:00 给我开盘前简报:宏观要闻、今日重要财报、我持仓公司的动态。",
"pre-market-brief.title": "开盘前简报",
"precious-metals-daily.description": "每天收盘后推送金银铜油主要品种的价格和日变化幅度,波动超阈值立刻标红",
"precious-metals-daily.prompt": "每天 16:00 收盘后给我金、银、铜、原油的当日价格和日变化幅度,单日波动超过 2% 立刻标红。",
"precious-metals-daily.title": "金银油价日报",
"recruit-funnel-daily.description": "每天早上看各岗位新投递、待面试、待反馈数,标出面试官拖着的人选",
"recruit-funnel-daily.prompt": "每天早上 9:00 按岗位汇总招聘漏斗:新投递、待面试、待反馈数,标出被面试官卡住的人选。",
"recruit-funnel-daily.title": "招聘漏斗日报",
"regulation-watch-weekly.description": "告诉我你关注的合规领域(数据 / 税务 / 劳动法),每周一给你一份变更摘要和影响判断",
"regulation-watch-weekly.prompt": "每周一早上 10:00 汇总我追踪的合规领域(数据 / 税务 / 劳动法)过去一周的变更,并判断对我们的影响。",
"regulation-watch-weekly.title": "法规变更追踪",
"renewal-risk-weekly.description": "每周一扫一遍本月到期合同,标出使用频次下降的高风险客户",
"renewal-risk-weekly.prompt": "每周一早上 9:00 扫一遍 HubSpot 中本月到期合同,标出使用频次下降的高风险客户,并为每条高风险账户建议挽留动作。",
"renewal-risk-weekly.title": "续费风险预警",
"repo-health-weekly.description": "每周一帮你看你维护的 repo:Issue 堆积、PR 停滞、CI 失败、依赖告警",
"repo-health-weekly.prompt": "每周一早上 9:00 检查我维护的 GitHub repoIssue 堆积、PR 停滞、CI 失败、依赖告警,挑出本周该处理的事项。",
"repo-health-weekly.title": "仓库健康周报",
"schedule.daily": "每天 {{time}}",
"schedule.weekly": "每{{weekday}} {{time}}",
"section.title": "试试这些定时任务",
"seo-weekly-report.description": "每周一一份轻量 SEO 报告:排名变化、新关键词机会、值得翻新的页面",
"seo-weekly-report.prompt": "每周一早上 9:00 给我一份轻量 SEO 周报:排名升 / 降 TOP 变动、5 个值得关注的新关键词、3 个适合内容翻新的老页面。",
"seo-weekly-report.title": "SEO 排名周报",
"series-update-weekly.description": "告诉我你在追的剧 / 小说 / 漫画,每周给你更新提醒和剧情回顾",
"series-update-weekly.prompt": "每周一早上 9:00 给我我在追的剧、小说、漫画的更新提醒和短剧情回顾。",
"series-update-weekly.title": "追更日报",
"standup-brief.description": "每天站会前 15 分钟帮你拉一份 Linear 进度简报:今日重点、阻塞项、昨日完成",
"standup-brief.prompt": "每天早上 8:30 拉一份 Linear 站会简报:今日重点、阻塞项、昨日完成,整理成 3 条站会前可直接念的要点。",
"standup-brief.title": "站会简报",
"sunday-reflection.description": "每周日晚陪你走 5 个问题:最有成就感的事、最想吐槽的事、下周 3 件重要事",
"sunday-reflection.prompt": "每周日晚 21:00 陪我走 5 个复盘问题:本周最有成就感的事、最想吐槽的事、下周 3 件重要事、本周学到的、本周应该放下的。",
"sunday-reflection.title": "周日复盘",
"team-status-weekly.description": "每周一看团队请假、加班、会议时长趋势,预警潜在倦怠信号",
"team-status-weekly.prompt": "每周一早上 9:00 回顾团队过去一周的请假、加班、会议时长趋势,预警有倦怠风险的成员。",
"team-status-weekly.title": "团队状态周报",
"tech-trend-weekly.description": "每周一帮你总结前端 / 后端 / AI 圈的重要动态:论文、框架、融资",
"tech-trend-weekly.prompt": "每周一早上 8:00 总结前端、后端、AI 圈过去一周的重要动态:论文、框架、融资,整理成 10 条 + 一句要点。",
"tech-trend-weekly.title": "技术趋势周刊",
"travel-inspiration-weekly.description": "每周三给你想去城市的机票价格变动、签证政策、最佳出行时间",
"travel-inspiration-weekly.prompt": "每周三早上 10:00 给我心愿城市的机票价格变化、签证政策、最佳出行时间。",
"travel-inspiration-weekly.title": "旅行灵感周报",
"twitter-weekly-recap.description": "每周一帮你复盘过去 7 天的推文表现:涨粉最猛的、互动最差的、为什么",
"twitter-weekly-recap.prompt": "每周一早上 10:00 复盘我 X (Twitter) 过去 7 天的推文表现:涨粉最猛的、互动最差的,并给出原因假设和下周 3 个尝试方向。",
"twitter-weekly-recap.title": "推特周报",
"user-feedback-daily.description": "每天把各渠道用户反馈(应用商店、社媒、客服)聚合成 TOP 20 条,按情绪和主题分类",
"user-feedback-daily.prompt": "每天早上 9:00 把各渠道用户反馈(应用商店、社媒、客服)聚合成 TOP 20 条,按情绪和主题分类。",
"user-feedback-daily.title": "用户反馈日报",
"user-interview-schedule.description": "每周一帮你梳理本周访谈:谁、什么时候、问题列表准备好没",
"user-interview-schedule.prompt": "每周一早上 9:00 列出本周已排期的用户访谈:访谈对象、时间、准备清单(问题列表是否齐全、录制设备是否就位)。",
"user-interview-schedule.title": "用户访谈排期",
"vercel-health-weekly.description": "每周一帮你盘点上周部署成功率、构建时长、流量异常",
"vercel-health-weekly.prompt": "每周一早上 10:00 盘点 Vercel 过去一周的部署:成功率、构建时长、流量异常,标出累积问题。",
"vercel-health-weekly.title": "Vercel 健康周报",
"viral-content-breakdown.description": "每天从你领域挑 1 条爆款帮你拆:选题、开头、结构、结尾",
"viral-content-breakdown.prompt": "每天早上 10:00 从我领域挑 1 条爆款帮我拆:选题、开头、结构、结尾,整理成可复刻的模板。",
"viral-content-breakdown.title": "爆款拆解",
"watchlist-friday.description": "每周五给你本周豆瓣 / IMDb 新上映的 5 部高分作品,附一句话短评",
"watchlist-friday.prompt": "每周五晚 18:00 从豆瓣和 IMDb 挑 5 部本周新上映的高分作品,每部附一句短评。",
"watchlist-friday.title": "观影清单",
"weekly-meeting-brief.description": "每周一早上帮你准备本周战略会的 3 个讨论要点:行业动态、内部指标、决策建议",
"weekly-meeting-brief.prompt": "每周一早上 8:30 准备本周战略会的 3 个讨论要点:行业动态、值得提的内部指标、需要决策的事项。",
"weekly-meeting-brief.title": "周会简报",
"youtube-channel-weekly.description": "每周一拉频道数据:订阅变化、热门视频、观众留存、收益变化",
"youtube-channel-weekly.prompt": "每周一早上 9:00 拉我 YouTube 频道的数据:订阅变化、热门视频、观众留存、收益变化。",
"youtube-channel-weekly.title": "YouTube 频道周报",
"youtube-weekly-recap.description": "每周一帮你拉频道过去 7 天的播放、CTR、留存曲线,标出值得拍续集的选题",
"youtube-weekly-recap.prompt": "每周一早上 9:00 拉我 YouTube 频道过去 7 天的播放、CTR、留存曲线,标出值得拍续集的选题。",
"youtube-weekly-recap.title": "YouTube 周报",
"zendesk-ticket-daily.description": "每天早上盘一下 Zendesk 工单:积压多少、SLA 逾期多少、重复问题前三名",
"zendesk-ticket-daily.prompt": "每天早上 9:00 给我 Zendesk 快照:未关单积压、SLA 逾期数量、过去 24 小时 TOP 3 重复问题。",
"zendesk-ticket-daily.title": "Zendesk 工单日报"
}
+1
View File
@@ -14,6 +14,7 @@ export * from './plugin';
export * from './recommendedSkill';
export * from './session';
export * from './settings';
export * from './taskTemplate';
export * from './theme';
export * from './trace';
export * from './url';
+96
View File
@@ -0,0 +1,96 @@
import { parseCronPattern } from '@lobechat/utils/cron';
import { describe, expect, it } from 'vitest';
import { TASK_TEMPLATE_FALLBACK_CATEGORIES, taskTemplates } from './taskTemplate';
const CRON_FIELDS = 5;
// Keep in sync with INTEREST_AREAS in lobehub/src/routes/onboarding/config.ts —
// those are the only values `users.interests` can hold.
const VALID_INTEREST_KEYS = new Set([
'writing',
'coding',
'design',
'education',
'business',
'marketing',
'product',
'sales',
'operations',
'hr',
'finance-legal',
'creator',
'investing',
'parenting',
'health',
'hobbies',
'personal',
]);
describe('taskTemplates', () => {
it('has the expected number of templates', () => {
expect(taskTemplates).toHaveLength(84);
});
it('has unique ids', () => {
const ids = taskTemplates.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('every template has non-empty interests from INTEREST_AREAS', () => {
for (const t of taskTemplates) {
expect(t.interests.length, `template ${t.id} interests`).toBeGreaterThan(0);
for (const key of t.interests) {
expect(VALID_INTEREST_KEYS.has(key), `template ${t.id} interest "${key}"`).toBe(true);
}
}
});
it('every template has a 5-field cron pattern', () => {
for (const t of taskTemplates) {
expect(t.cronPattern.trim().split(/\s+/), `template ${t.id} cron`).toHaveLength(CRON_FIELDS);
}
});
// parseCronPattern only renders 'daily' / 'weekly' / 'hourly' schedule strings.
// Monthly or event-driven cron patterns silently fall back to daily display —
// guard against accidental introduction here.
it('every template parses to daily or weekly schedule', () => {
for (const t of taskTemplates) {
const { scheduleType } = parseCronPattern(t.cronPattern);
expect(['daily', 'weekly'], `template ${t.id} scheduleType`).toContain(scheduleType);
}
});
it('covers every fallback category at least once', () => {
const categories = new Set(taskTemplates.map((t) => t.category));
for (const fallback of TASK_TEMPLATE_FALLBACK_CATEGORIES) {
expect(categories.has(fallback), `fallback category ${fallback}`).toBe(true);
}
});
it('every optionalSkills entry uses a valid source and non-empty provider', () => {
for (const t of taskTemplates) {
if (!t.optionalSkills) continue;
for (const spec of t.optionalSkills) {
expect(['klavis', 'lobehub'], `template ${t.id} optional source`).toContain(spec.source);
expect(
spec.provider.length,
`template ${t.id} optional provider "${spec.provider}"`,
).toBeGreaterThan(0);
}
}
});
it('optionalSkills do not duplicate requiresSkills', () => {
for (const t of taskTemplates) {
if (!t.optionalSkills || !t.requiresSkills) continue;
const reqKeys = new Set(t.requiresSkills.map((s) => `${s.source}:${s.provider}`));
for (const spec of t.optionalSkills) {
expect(
reqKeys.has(`${spec.source}:${spec.provider}`),
`template ${t.id} duplicate skill ${spec.source}:${spec.provider}`,
).toBe(false);
}
}
});
});
+635
View File
@@ -0,0 +1,635 @@
import type { IconType } from '@icons-pack/react-simple-icons';
import { SiGithub } from '@icons-pack/react-simple-icons';
import type { LucideIcon } from 'lucide-react';
/**
* Task Template catalog used by home "Try following tasks" recommendation.
* I18n keys: `taskTemplate:${id}.title|description|prompt`.
*
* `interests` values must be keys from `INTEREST_AREAS` in
* `src/routes/onboarding/config.ts` — that's what `users.interests` stores.
*/
export interface TaskTemplate {
category: TaskTemplateCategory;
cronPattern: string;
/** Per-template icon override. Falls back to a category-level default when omitted. */
icon?: IconType | LucideIcon;
id: string;
interests: string[];
/** Skills that enrich the brief but are not required to run it. */
optionalSkills?: TaskTemplateSkillRequirement[];
/** Skill dependencies. The `source` field routes the connection flow. */
requiresSkills?: TaskTemplateSkillRequirement[];
}
export interface TaskTemplateSkillRequirement {
/** Short identifier from `LOBEHUB_SKILL_PROVIDERS[i].id` or `KLAVIS_SERVER_TYPES[i].identifier`. */
provider: string;
source: TaskTemplateSkillSource;
}
export type TaskTemplateSkillSource = 'klavis' | 'lobehub';
export type TaskTemplateCategory =
| 'content-creation'
| 'engineering'
| 'design'
| 'learning-research'
| 'business'
| 'marketing'
| 'product'
| 'sales-customer'
| 'operations'
| 'hr'
| 'finance-legal'
| 'creator'
| 'investing'
| 'parenting'
| 'health'
| 'hobbies'
| 'personal-life';
/** Generic categories used to fill the pool when interest-matched picks are insufficient. */
export const TASK_TEMPLATE_FALLBACK_CATEGORIES: TaskTemplateCategory[] = [
'personal-life',
'learning-research',
];
export const taskTemplates: TaskTemplate[] = [
// content-creation
{
id: 'daily-topic-pick',
category: 'content-creation',
cronPattern: '0 9 * * *',
interests: ['writing'],
},
{
id: 'hot-topic-radar',
category: 'content-creation',
cronPattern: '0 10 * * *',
interests: ['writing'],
},
{
id: 'headline-inspiration',
category: 'content-creation',
cronPattern: '0 10 * * *',
interests: ['writing'],
},
{
id: 'viral-content-breakdown',
category: 'content-creation',
cronPattern: '0 10 * * *',
interests: ['writing'],
},
{
id: 'twitter-weekly-recap',
category: 'content-creation',
cronPattern: '0 10 * * 1',
interests: ['writing', 'creator'],
requiresSkills: [{ provider: 'twitter', source: 'lobehub' }],
},
{
id: 'youtube-weekly-recap',
category: 'content-creation',
cronPattern: '0 9 * * 1',
interests: ['writing', 'creator'],
requiresSkills: [{ provider: 'youtube', source: 'klavis' }],
},
{
id: 'competitor-creator-tracking',
category: 'content-creation',
cronPattern: '0 9 * * *',
interests: ['writing', 'creator'],
},
{
id: 'content-calendar-weekly',
category: 'content-creation',
cronPattern: '0 20 * * 0',
interests: ['writing', 'creator'],
optionalSkills: [{ provider: 'notion', source: 'klavis' }],
},
// engineering
{
id: 'oss-intel-daily',
category: 'engineering',
cronPattern: '0 9 * * *',
icon: SiGithub,
interests: ['coding'],
},
{
id: 'repo-health-weekly',
category: 'engineering',
cronPattern: '0 9 * * 1',
interests: ['coding'],
requiresSkills: [{ provider: 'github', source: 'lobehub' }],
},
{
id: 'dependency-security-weekly',
category: 'engineering',
cronPattern: '0 10 * * 1',
interests: ['coding'],
requiresSkills: [{ provider: 'github', source: 'lobehub' }],
},
{
id: 'vercel-health-weekly',
category: 'engineering',
cronPattern: '0 10 * * 1',
interests: ['coding'],
requiresSkills: [{ provider: 'vercel', source: 'lobehub' }],
},
{
id: 'linear-sprint-daily',
category: 'engineering',
cronPattern: '30 8 * * *',
interests: ['coding', 'product'],
requiresSkills: [{ provider: 'linear', source: 'lobehub' }],
},
{
id: 'tech-trend-weekly',
category: 'engineering',
cronPattern: '0 8 * * 1',
interests: ['coding'],
},
{
id: 'keyword-tech-feed',
category: 'engineering',
cronPattern: '0 10 * * *',
interests: ['coding'],
},
// design
{
id: 'daily-design-inspiration',
category: 'design',
cronPattern: '0 9 * * *',
interests: ['design'],
},
{
id: 'design-trend-weekly',
category: 'design',
cronPattern: '0 9 * * 1',
interests: ['design'],
},
{
id: 'figma-files-cleanup',
category: 'design',
cronPattern: '0 17 * * 5',
interests: ['design'],
requiresSkills: [{ provider: 'figma', source: 'klavis' }],
},
{
id: 'aigc-prompt-inspiration',
category: 'design',
cronPattern: '0 10 * * *',
interests: ['design'],
},
{
id: 'brand-watch-weekly',
category: 'design',
cronPattern: '0 10 * * 1',
interests: ['design'],
},
{
id: 'font-color-weekly',
category: 'design',
cronPattern: '0 10 * * 3',
interests: ['design'],
},
// learning-research
{
id: 'arxiv-curated-daily',
category: 'learning-research',
cronPattern: '0 9 * * *',
interests: ['education'],
},
{
id: 'must-read-papers-weekly',
category: 'learning-research',
cronPattern: '0 20 * * 0',
interests: ['education'],
},
{
id: 'language-morning-bite',
category: 'learning-research',
cronPattern: '30 7 * * *',
interests: ['education'],
},
{
id: 'industry-research-weekly',
category: 'learning-research',
cronPattern: '0 9 * * 1',
interests: ['education', 'business'],
},
// business
{
id: 'industry-morning-brief',
category: 'business',
cronPattern: '0 8 * * *',
interests: ['business'],
},
{
id: 'competitor-radar-daily',
category: 'business',
cronPattern: '0 9 * * *',
interests: ['business'],
},
{
id: 'funding-intel-daily',
category: 'business',
cronPattern: '0 10 * * *',
interests: ['business'],
},
{
id: 'macro-economy-weekly',
category: 'business',
cronPattern: '0 8 * * 1',
interests: ['business', 'investing'],
},
{
id: 'weekly-meeting-brief',
category: 'business',
cronPattern: '30 8 * * 1',
interests: ['business'],
},
// marketing
{
id: 'marketing-hot-radar',
category: 'marketing',
cronPattern: '0 10 * * *',
interests: ['marketing'],
},
{
id: 'ad-creative-inspiration',
category: 'marketing',
cronPattern: '0 10 * * *',
interests: ['marketing'],
},
{
id: 'brand-mention-daily',
category: 'marketing',
cronPattern: '0 18 * * *',
interests: ['marketing'],
requiresSkills: [{ provider: 'twitter', source: 'lobehub' }],
},
{
id: 'seo-weekly-report',
category: 'marketing',
cronPattern: '0 9 * * 1',
interests: ['marketing'],
},
{
id: 'newsletter-perf-weekly',
category: 'marketing',
cronPattern: '0 10 * * 1',
interests: ['marketing'],
requiresSkills: [{ provider: 'gmail', source: 'klavis' }],
},
{
id: 'kol-collab-calendar',
category: 'marketing',
cronPattern: '0 9 * * 1',
interests: ['marketing'],
requiresSkills: [{ provider: 'airtable', source: 'klavis' }],
},
{
id: 'hubspot-funnel-daily',
category: 'marketing',
cronPattern: '0 9 * * *',
interests: ['marketing', 'sales'],
requiresSkills: [{ provider: 'hubspot', source: 'klavis' }],
},
// product
{
id: 'user-feedback-daily',
category: 'product',
cronPattern: '0 9 * * *',
interests: ['product'],
},
{
id: 'competitor-update-daily',
category: 'product',
cronPattern: '0 10 * * *',
interests: ['product'],
},
{
id: 'standup-brief',
category: 'product',
cronPattern: '30 8 * * *',
interests: ['product'],
requiresSkills: [{ provider: 'linear', source: 'lobehub' }],
},
{
id: 'iteration-recap-weekly',
category: 'product',
cronPattern: '0 17 * * 5',
interests: ['product'],
requiresSkills: [{ provider: 'linear', source: 'lobehub' }],
},
{
id: 'core-metric-daily',
category: 'product',
cronPattern: '0 9 * * *',
interests: ['product'],
},
{
id: 'user-interview-schedule',
category: 'product',
cronPattern: '0 9 * * 1',
interests: ['product'],
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
},
{
id: 'prd-review-reminder',
category: 'product',
cronPattern: '0 15 * * 5',
interests: ['product'],
requiresSkills: [{ provider: 'notion', source: 'klavis' }],
},
// sales-customer
{
id: 'daily-followup-list',
category: 'sales-customer',
cronPattern: '0 9 * * *',
interests: ['sales'],
requiresSkills: [{ provider: 'hubspot', source: 'klavis' }],
},
{
id: 'renewal-risk-weekly',
category: 'sales-customer',
cronPattern: '0 9 * * 1',
interests: ['sales'],
requiresSkills: [{ provider: 'hubspot', source: 'klavis' }],
},
{
id: 'deal-pipeline-weekly',
category: 'sales-customer',
cronPattern: '0 16 * * 5',
interests: ['sales'],
requiresSkills: [{ provider: 'hubspot', source: 'klavis' }],
},
{
id: 'key-account-radar',
category: 'sales-customer',
cronPattern: '0 9 * * *',
interests: ['sales'],
},
{
id: 'zendesk-ticket-daily',
category: 'sales-customer',
cronPattern: '0 9 * * *',
interests: ['sales'],
requiresSkills: [{ provider: 'zendesk', source: 'klavis' }],
},
// operations
{
id: 'morning-brief',
category: 'operations',
cronPattern: '0 8 * * *',
interests: ['operations'],
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
},
{
id: 'meeting-brief',
category: 'operations',
cronPattern: '30 8 * * *',
interests: ['operations'],
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
},
{
id: 'calendar-conflict-check',
category: 'operations',
cronPattern: '30 7 * * *',
interests: ['operations'],
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
},
{
id: 'friday-wrap-list',
category: 'operations',
cronPattern: '0 16 * * 5',
interests: ['operations'],
requiresSkills: [{ provider: 'linear', source: 'lobehub' }],
},
// hr
{
id: 'recruit-funnel-daily',
category: 'hr',
cronPattern: '0 9 * * *',
interests: ['hr'],
requiresSkills: [{ provider: 'airtable', source: 'klavis' }],
},
{
id: 'onboarding-buddy-weekly',
category: 'hr',
cronPattern: '0 9 * * 1',
interests: ['hr'],
requiresSkills: [{ provider: 'notion', source: 'klavis' }],
},
{
id: 'team-status-weekly',
category: 'hr',
cronPattern: '0 9 * * 1',
interests: ['hr'],
requiresSkills: [{ provider: 'google-calendar', source: 'klavis' }],
},
// finance-legal
{
id: 'precious-metals-daily',
category: 'finance-legal',
cronPattern: '0 16 * * *',
interests: ['finance-legal', 'investing'],
},
{
id: 'pre-market-brief',
category: 'finance-legal',
cronPattern: '0 9 * * *',
interests: ['finance-legal', 'investing'],
},
{
id: 'cashflow-weekly',
category: 'finance-legal',
cronPattern: '0 9 * * 1',
interests: ['finance-legal'],
requiresSkills: [{ provider: 'airtable', source: 'klavis' }],
},
{
id: 'contract-expiry-weekly',
category: 'finance-legal',
cronPattern: '0 9 * * 1',
interests: ['finance-legal'],
requiresSkills: [{ provider: 'notion', source: 'klavis' }],
},
{
id: 'regulation-watch-weekly',
category: 'finance-legal',
cronPattern: '0 10 * * 1',
interests: ['finance-legal'],
},
{
id: 'invoice-collection-daily',
category: 'finance-legal',
cronPattern: '0 10 * * *',
interests: ['finance-legal'],
requiresSkills: [{ provider: 'gmail', source: 'klavis' }],
},
// creator
{
id: 'cross-platform-engagement-daily',
category: 'creator',
cronPattern: '0 9 * * *',
interests: ['creator'],
requiresSkills: [{ provider: 'twitter', source: 'lobehub' }],
},
{
id: 'brand-collab-weekly',
category: 'creator',
cronPattern: '0 10 * * 1',
interests: ['creator'],
},
{
id: 'follower-growth-weekly',
category: 'creator',
cronPattern: '0 10 * * 1',
interests: ['creator'],
requiresSkills: [{ provider: 'twitter', source: 'lobehub' }],
},
{
id: 'youtube-channel-weekly',
category: 'creator',
cronPattern: '0 9 * * 1',
interests: ['creator'],
requiresSkills: [{ provider: 'youtube', source: 'klavis' }],
},
{
id: 'monetization-opportunity-weekly',
category: 'creator',
cronPattern: '0 10 * * 3',
interests: ['creator'],
},
// investing
{
id: 'portfolio-daily',
category: 'investing',
cronPattern: '0 16 * * *',
interests: ['investing'],
},
{
id: 'crypto-market-daily',
category: 'investing',
cronPattern: '0 9 * * *',
interests: ['investing'],
},
// parenting
{
id: 'child-growth-weekly',
category: 'parenting',
cronPattern: '0 9 * * 1',
interests: ['parenting'],
},
{
id: 'child-study-weekly',
category: 'parenting',
cronPattern: '0 20 * * 0',
interests: ['parenting', 'education'],
},
{
id: 'family-finance-weekly',
category: 'parenting',
cronPattern: '0 20 * * 0',
interests: ['parenting', 'finance-legal'],
requiresSkills: [{ provider: 'google-sheets', source: 'klavis' }],
},
{
id: 'family-task-schedule',
category: 'parenting',
cronPattern: '0 8 * * 1',
interests: ['parenting'],
optionalSkills: [{ provider: 'google-calendar', source: 'klavis' }],
},
// health
{
id: 'diet-log-companion',
category: 'health',
cronPattern: '0 21 * * *',
interests: ['health'],
},
// hobbies
{
id: 'podcast-new-episodes',
category: 'hobbies',
cronPattern: '0 9 * * 1',
interests: ['hobbies'],
},
{
id: 'newsletter-aggregator',
category: 'hobbies',
cronPattern: '0 20 * * 0',
interests: ['hobbies'],
requiresSkills: [{ provider: 'gmail', source: 'klavis' }],
},
{
id: 'series-update-weekly',
category: 'hobbies',
cronPattern: '0 9 * * 1',
interests: ['hobbies'],
},
{
id: 'travel-inspiration-weekly',
category: 'hobbies',
cronPattern: '0 10 * * 3',
interests: ['hobbies'],
},
{
id: 'watchlist-friday',
category: 'hobbies',
cronPattern: '0 18 * * 5',
interests: ['hobbies'],
},
{
id: 'exhibition-event-weekly',
category: 'hobbies',
cronPattern: '0 10 * * 1',
interests: ['hobbies'],
},
// personal-life
{
id: 'daily-learning-bite',
category: 'personal-life',
cronPattern: '30 7 * * *',
interests: ['education', 'personal'],
},
{
id: 'sunday-reflection',
category: 'personal-life',
cronPattern: '0 21 * * 0',
interests: ['personal'],
},
{
id: 'morning-ritual',
category: 'personal-life',
cronPattern: '0 7 * * *',
interests: ['personal'],
optionalSkills: [{ provider: 'google-calendar', source: 'klavis' }],
},
{
id: 'bedtime-gratitude',
category: 'personal-life',
cronPattern: '0 22 * * *',
interests: ['personal'],
optionalSkills: [{ provider: 'notion', source: 'klavis' }],
},
];
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
},
});
+160
View File
@@ -0,0 +1,160 @@
import dayjs from 'dayjs';
import { describe, expect, it } from 'vitest';
import { buildCronPattern, formatScheduleTime, parseCronPattern, WEEKDAY_I18N_KEYS } from './cron';
describe('parseCronPattern', () => {
describe('daily', () => {
it('parses every-day-at-9am', () => {
expect(parseCronPattern('0 9 * * *')).toEqual({
scheduleType: 'daily',
triggerHour: 9,
triggerMinute: 0,
});
});
it('parses 7:30am (minute=30 stays 30 after normalization)', () => {
expect(parseCronPattern('30 7 * * *')).toEqual({
scheduleType: 'daily',
triggerHour: 7,
triggerMinute: 30,
});
});
it('parses evening hour', () => {
expect(parseCronPattern('0 19 * * *')).toEqual({
scheduleType: 'daily',
triggerHour: 19,
triggerMinute: 0,
});
});
});
describe('weekly', () => {
it('parses single weekday (Monday)', () => {
expect(parseCronPattern('0 9 * * 1')).toEqual({
scheduleType: 'weekly',
triggerHour: 9,
triggerMinute: 0,
weekdays: [1],
});
});
it('parses Friday afternoon', () => {
expect(parseCronPattern('0 15 * * 5')).toEqual({
scheduleType: 'weekly',
triggerHour: 15,
triggerMinute: 0,
weekdays: [5],
});
});
it('parses comma-separated weekdays', () => {
expect(parseCronPattern('0 9 * * 1,3,5')).toEqual({
scheduleType: 'weekly',
triggerHour: 9,
triggerMinute: 0,
weekdays: [1, 3, 5],
});
});
});
describe('hourly', () => {
it('parses every-hour (*)', () => {
expect(parseCronPattern('0 * * * *')).toEqual({
hourlyInterval: 1,
scheduleType: 'hourly',
triggerHour: 0,
triggerMinute: 0,
});
});
it('parses every-N-hours (*/N)', () => {
expect(parseCronPattern('0 */6 * * *')).toEqual({
hourlyInterval: 6,
scheduleType: 'hourly',
triggerHour: 0,
triggerMinute: 0,
});
});
});
describe('minute normalization', () => {
it.each([
[0, 0],
[14, 0],
[15, 30],
[29, 30],
[30, 30],
[44, 30],
[45, 0],
[59, 0],
])('normalizes minute %i to %i', (input, expected) => {
const parsed = parseCronPattern(`${input} 9 * * *`);
expect(parsed.triggerMinute).toBe(expected);
});
});
describe('fallback', () => {
it.each([
['empty', ''],
['too few fields', '0 9 * *'],
['too many fields', '0 9 * * * *'],
])('falls back to daily 0:00 for %s', (_label, cron) => {
expect(parseCronPattern(cron)).toEqual({
scheduleType: 'daily',
triggerHour: 0,
triggerMinute: 0,
});
});
});
});
describe('buildCronPattern', () => {
const at = (h: number, m: number) => dayjs().hour(h).minute(m);
it('builds daily pattern', () => {
expect(buildCronPattern('daily', at(9, 0))).toBe('0 9 * * *');
expect(buildCronPattern('daily', at(7, 30))).toBe('30 7 * * *');
});
it('builds weekly pattern with weekdays', () => {
expect(buildCronPattern('weekly', at(9, 0), undefined, [1])).toBe('0 9 * * 1');
expect(buildCronPattern('weekly', at(10, 0), undefined, [5, 1, 3])).toBe('0 10 * * 1,3,5');
});
it('builds weekly with all weekdays when none specified', () => {
expect(buildCronPattern('weekly', at(9, 0))).toBe('0 9 * * 0,1,2,3,4,5,6');
});
it('builds hourly pattern with interval 1 as star', () => {
expect(buildCronPattern('hourly', at(0, 0), 1)).toBe('0 * * * *');
});
it('builds hourly pattern with N-interval', () => {
expect(buildCronPattern('hourly', at(0, 30), 6)).toBe('30 */6 * * *');
});
it('normalizes raw minutes to 0 or 30', () => {
expect(buildCronPattern('daily', at(9, 14))).toBe('0 9 * * *');
expect(buildCronPattern('daily', at(9, 20))).toBe('30 9 * * *');
expect(buildCronPattern('daily', at(9, 50))).toBe('0 9 * * *');
});
});
describe('formatScheduleTime', () => {
it('zero-pads hours and minutes', () => {
expect(formatScheduleTime(9, 0)).toBe('09:00');
expect(formatScheduleTime(7, 30)).toBe('07:30');
expect(formatScheduleTime(15, 5)).toBe('15:05');
expect(formatScheduleTime(0, 0)).toBe('00:00');
});
});
describe('WEEKDAY_I18N_KEYS', () => {
it('orders Sunday-first to match cron weekday numbering', () => {
expect(WEEKDAY_I18N_KEYS[0]).toBe('sunday');
expect(WEEKDAY_I18N_KEYS[1]).toBe('monday');
expect(WEEKDAY_I18N_KEYS[6]).toBe('saturday');
});
});
+131
View File
@@ -0,0 +1,131 @@
import type { Dayjs } from 'dayjs';
export type ScheduleType = 'daily' | 'hourly' | 'weekly';
/** Schedule UI only exposes :00 / :30 — minutes are normalized to one of these. */
const SCHEDULE_MINUTE_STEP = 30;
/** Cron weekday list when no specific weekdays are selected (Sun..Sat). */
const ALL_WEEKDAYS = '0,1,2,3,4,5,6';
const normalizeMinuteToHalfHour = (raw: number): 0 | 30 =>
raw >= SCHEDULE_MINUTE_STEP / 2 && raw < SCHEDULE_MINUTE_STEP + SCHEDULE_MINUTE_STEP / 2 ? 30 : 0;
export interface ParsedSchedule {
hourlyInterval?: number;
scheduleType: ScheduleType;
triggerHour: number;
triggerMinute: number;
weekdays?: number[];
}
/**
* i18n key suffixes for cron weekday numbers (0=Sunday, 1=Monday, ..., 6=Saturday).
* Combine with `setting:agentCronJobs.weekday.${WEEKDAY_I18N_KEYS[n]}`.
*/
export const WEEKDAY_I18N_KEYS = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
] as const;
export type WeekdayI18nKey = (typeof WEEKDAY_I18N_KEYS)[number];
/** Format `HH:mm` from numeric hour/minute, zero-padded. */
export const formatScheduleTime = (hour: number, minute: number): string =>
`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
/**
* Parse cron pattern to extract schedule info.
* Format: minute hour day month weekday
*
* Falls back to `{ scheduleType: 'daily', triggerHour: 0, triggerMinute: 0 }`
* for malformed or unsupported patterns — matches the legacy behavior used by
* the cron-edit form.
*/
export const parseCronPattern = (cronPattern: string): ParsedSchedule => {
const parts = cronPattern.trim().split(/\s+/);
if (parts.length !== 5) {
return { scheduleType: 'daily', triggerHour: 0, triggerMinute: 0 };
}
const [minute, hour, , , weekday] = parts;
const rawMinute = minute === '*' ? 0 : Number.parseInt(minute, 10);
const triggerMinute = normalizeMinuteToHalfHour(rawMinute);
// Hourly: 0 * * * * or 0 */N * * *
if (hour.startsWith('*/')) {
const interval = Number.parseInt(hour.slice(2), 10);
return {
hourlyInterval: interval,
scheduleType: 'hourly',
triggerHour: 0,
triggerMinute,
};
}
if (hour === '*') {
return {
hourlyInterval: 1,
scheduleType: 'hourly',
triggerHour: 0,
triggerMinute,
};
}
const triggerHour = Number.parseInt(hour, 10);
// Weekly: has specific weekday(s)
if (weekday !== '*') {
const weekdays = weekday.split(',').map((d) => Number.parseInt(d, 10));
return {
scheduleType: 'weekly',
triggerHour,
triggerMinute,
weekdays,
};
}
// Daily: specific hour, any weekday
return {
scheduleType: 'daily',
triggerHour,
triggerMinute,
};
};
/**
* Build cron pattern from schedule info.
* Format: minute hour day month weekday
*/
export const buildCronPattern = (
scheduleType: ScheduleType,
triggerTime: Dayjs,
hourlyInterval?: number,
weekdays?: number[],
): string => {
const minute = normalizeMinuteToHalfHour(triggerTime.minute());
const hour = triggerTime.hour();
switch (scheduleType) {
case 'hourly': {
const interval = hourlyInterval || 1;
if (interval === 1) {
return `${minute} * * * *`;
}
return `${minute} */${interval} * * *`;
}
case 'daily': {
return `${minute} ${hour} * * *`;
}
case 'weekly': {
const days =
weekdays && weekdays.length > 0
? [...weekdays].sort((a, b) => a - b).join(',')
: ALL_WEEKDAYS;
return `${minute} ${hour} * * ${days}`;
}
}
};
@@ -0,0 +1,5 @@
import { memo } from 'react';
const RecommendTaskTemplates = memo(() => null);
export default RecommendTaskTemplates;
@@ -0,0 +1,3 @@
import { router } from '@/libs/trpc/lambda';
export const taskTemplateRouter = router({});
+5
View File
@@ -0,0 +1,5 @@
/**
* postMessage type the OAuth callback page (`/oauth/callback/success`)
* sends back to the opener so the connect flow knows the popup is done.
*/
export const LOBEHUB_SKILL_AUTH_SUCCESS_MESSAGE = 'LOBEHUB_SKILL_AUTH_SUCCESS';
+1 -2
View File
@@ -24,8 +24,7 @@ const DailyBrief = memo(() => {
const briefs = useBriefStore(briefListSelectors.briefs);
const isInit = useBriefStore(briefListSelectors.isBriefsInit);
if (!enableAgentTask) return null;
if (!isInit || briefs.length === 0) return null;
if (!enableAgentTask || !isInit || briefs.length === 0) return null;
return (
<GroupBlock
+79
View File
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import type { TaskTemplateSkillRequirement } from '@lobechat/const';
import { describe, expect, it } from 'vitest';
import { findNextUnconnectedSpec, getProviderMeta } from './useSkillConnection';
describe('getProviderMeta', () => {
it('resolves lobehub source via LOBEHUB_SKILL_PROVIDERS', () => {
const meta = getProviderMeta({ provider: 'github', source: 'lobehub' });
expect(meta).toMatchObject({ label: 'GitHub', provider: 'github', source: 'lobehub' });
expect(meta?.icon).toBeDefined();
});
it('resolves klavis source via KLAVIS_SERVER_TYPES', () => {
const meta = getProviderMeta({ provider: 'notion', source: 'klavis' });
expect(meta).toMatchObject({ label: 'Notion', provider: 'notion', source: 'klavis' });
expect(meta?.icon).toBeDefined();
});
it('returns undefined for unknown provider', () => {
expect(getProviderMeta({ provider: 'nonexistent-x', source: 'lobehub' })).toBeUndefined();
expect(getProviderMeta({ provider: 'nonexistent-x', source: 'klavis' })).toBeUndefined();
});
it('does not cross namespaces (lobehub id under klavis source returns undefined)', () => {
// 'github' is a lobehub provider id, not a klavis identifier.
expect(getProviderMeta({ provider: 'github', source: 'klavis' })).toBeUndefined();
});
});
describe('findNextUnconnectedSpec', () => {
const allConnected = () => true;
const noneConnected = () => false;
it('returns undefined when specs is undefined or empty', () => {
expect(findNextUnconnectedSpec(undefined, noneConnected)).toBeUndefined();
expect(findNextUnconnectedSpec([], noneConnected)).toBeUndefined();
});
it('returns undefined when all specs are connected', () => {
const specs: TaskTemplateSkillRequirement[] = [
{ provider: 'github', source: 'lobehub' },
{ provider: 'notion', source: 'klavis' },
];
expect(findNextUnconnectedSpec(specs, allConnected)).toBeUndefined();
});
it('returns the first spec when none are connected', () => {
const specs: TaskTemplateSkillRequirement[] = [
{ provider: 'github', source: 'lobehub' },
{ provider: 'notion', source: 'klavis' },
];
const result = findNextUnconnectedSpec(specs, noneConnected);
expect(result?.provider).toBe('github');
expect(result?.label).toBe('GitHub');
});
it('skips already-connected specs and returns the next missing one in order', () => {
const specs: TaskTemplateSkillRequirement[] = [
{ provider: 'github', source: 'lobehub' },
{ provider: 'linear', source: 'lobehub' },
{ provider: 'notion', source: 'klavis' },
];
const isConnected = (s: TaskTemplateSkillRequirement) =>
s.provider === 'github' || s.provider === 'linear';
const result = findNextUnconnectedSpec(specs, isConnected);
expect(result?.provider).toBe('notion');
expect(result?.source).toBe('klavis');
});
it('skips specs with unknown providers (no meta) and continues searching', () => {
const specs: TaskTemplateSkillRequirement[] = [
{ provider: 'nonexistent-x', source: 'lobehub' },
{ provider: 'notion', source: 'klavis' },
];
const result = findNextUnconnectedSpec(specs, noneConnected);
expect(result?.provider).toBe('notion');
});
});
+296
View File
@@ -0,0 +1,296 @@
'use client';
import type {
LobehubSkillProviderType,
TaskTemplateSkillRequirement,
TaskTemplateSkillSource,
} from '@lobechat/const';
import {
getKlavisServerByServerIdentifier,
getLobehubSkillProviderById,
KLAVIS_SERVER_TYPES,
} from '@lobechat/const';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LOBEHUB_SKILL_AUTH_SUCCESS_MESSAGE } from '@/const/skillConnection';
import { useToolStore } from '@/store/tool';
import { klavisStoreSelectors } from '@/store/tool/slices/klavisStore/selectors';
import { KlavisServerStatus } from '@/store/tool/slices/klavisStore/types';
import { lobehubSkillStoreSelectors } from '@/store/tool/slices/lobehubSkillStore/selectors';
import { LobehubSkillStatus } from '@/store/tool/slices/lobehubSkillStore/types';
import { useUserStore } from '@/store/user';
const POLL_INTERVAL_MS = 1000;
const POLL_TIMEOUT_MS = 15_000;
/** Hard cap on how long the OAuth popup-monitor keeps polling — protects against
* users opening the popup, switching away, and never closing it. */
const OAUTH_OVERALL_TIMEOUT_MS = 5 * 60 * 1000;
export interface SkillProviderMeta {
icon: LobehubSkillProviderType['icon'];
label: string;
provider: string;
source: TaskTemplateSkillSource;
}
type ConnectTarget = Pick<SkillProviderMeta, 'provider' | 'source'>;
export interface UseSkillConnectionResult {
connect: () => Promise<void>;
isAllConnected: boolean;
isConnecting: boolean;
/** True when there is at least one spec and at least one of them is not yet connected. */
needsConnect: boolean;
/** First spec in input order whose connection is missing. undefined when all connected or specs is empty. */
nextUnconnected: SkillProviderMeta | undefined;
}
export const getProviderMeta = (
spec: TaskTemplateSkillRequirement,
): SkillProviderMeta | undefined => {
if (spec.source === 'lobehub') {
const p = getLobehubSkillProviderById(spec.provider);
if (!p) return undefined;
return { icon: p.icon, label: p.label, provider: spec.provider, source: 'lobehub' };
}
const p = getKlavisServerByServerIdentifier(spec.provider);
if (!p) return undefined;
return { icon: p.icon, label: p.label, provider: spec.provider, source: 'klavis' };
};
export const findNextUnconnectedSpec = (
specs: TaskTemplateSkillRequirement[] | undefined,
isConnected: (spec: TaskTemplateSkillRequirement) => boolean,
): SkillProviderMeta | undefined => {
if (!specs || specs.length === 0) return undefined;
for (const spec of specs) {
if (isConnected(spec)) continue;
const meta = getProviderMeta(spec);
if (!meta) continue;
return meta;
}
return undefined;
};
export const useSkillConnection = (
specs: TaskTemplateSkillRequirement[] | undefined,
): UseSkillConnectionResult => {
const getLobehubAuth = useToolStore((s) => s.getLobehubSkillAuthorizeUrl);
const checkLobehubStatus = useToolStore((s) => s.checkLobehubSkillStatus);
const createKlavisServer = useToolStore((s) => s.createKlavisServer);
const refreshKlavisServerTools = useToolStore((s) => s.refreshKlavisServerTools);
const lobehubServers = useToolStore(lobehubSkillStoreSelectors.getServers);
const klavisServers = useToolStore(klavisStoreSelectors.getServers);
const isConnectedFor = useCallback(
(spec: TaskTemplateSkillRequirement): boolean => {
if (spec.source === 'lobehub') {
return lobehubServers.some(
(s) => s.identifier === spec.provider && s.status === LobehubSkillStatus.CONNECTED,
);
}
return klavisServers.some(
(s) => s.identifier === spec.provider && s.status === KlavisServerStatus.CONNECTED,
);
},
[lobehubServers, klavisServers],
);
const nextUnconnected = useMemo(
() => findNextUnconnectedSpec(specs, isConnectedFor),
[specs, isConnectedFor],
);
const hasSpecs = (specs?.length ?? 0) > 0;
const isAllConnected = hasSpecs && !nextUnconnected;
const needsConnect = hasSpecs && !!nextUnconnected;
const [isConnecting, setIsConnecting] = useState(false);
const [isWaitingAuth, setIsWaitingAuth] = useState(false);
const oauthWindowRef = useRef<Window | null>(null);
const windowCheckIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Sync lock against double-click — useState guard would only flip after re-render.
const isConnectingRef = useRef(false);
const cleanup = useCallback(() => {
if (windowCheckIntervalRef.current) {
clearInterval(windowCheckIntervalRef.current);
windowCheckIntervalRef.current = null;
}
if (windowCheckTimeoutRef.current) {
clearTimeout(windowCheckTimeoutRef.current);
windowCheckTimeoutRef.current = null;
}
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
oauthWindowRef.current = null;
setIsWaitingAuth(false);
}, []);
useEffect(() => () => cleanup(), [cleanup]);
useEffect(() => {
if (isWaitingAuth && !nextUnconnected) cleanup();
}, [isWaitingAuth, nextUnconnected, cleanup]);
const startFallbackPolling = useCallback(
(target: ConnectTarget) => {
if (pollIntervalRef.current) return;
pollIntervalRef.current = setInterval(async () => {
try {
if (target.source === 'lobehub') {
await checkLobehubStatus(target.provider);
} else {
await refreshKlavisServerTools(target.provider);
}
} catch {
// Polling failure is expected until auth completes — suppress noise.
}
}, POLL_INTERVAL_MS);
pollTimeoutRef.current = setTimeout(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setIsWaitingAuth(false);
}, POLL_TIMEOUT_MS);
},
[checkLobehubStatus, refreshKlavisServerTools],
);
const startWindowMonitor = useCallback(
(oauthWindow: Window, target: ConnectTarget) => {
const stopMonitor = () => {
if (windowCheckIntervalRef.current) {
clearInterval(windowCheckIntervalRef.current);
windowCheckIntervalRef.current = null;
}
if (windowCheckTimeoutRef.current) {
clearTimeout(windowCheckTimeoutRef.current);
windowCheckTimeoutRef.current = null;
}
};
windowCheckIntervalRef.current = setInterval(() => {
try {
if (oauthWindow.closed) {
stopMonitor();
oauthWindowRef.current = null;
startFallbackPolling(target);
}
} catch {
// COOP can block window.closed access — fall back to polling.
stopMonitor();
startFallbackPolling(target);
}
}, 500);
windowCheckTimeoutRef.current = setTimeout(() => {
stopMonitor();
oauthWindowRef.current = null;
setIsWaitingAuth(false);
}, OAUTH_OVERALL_TIMEOUT_MS);
},
[startFallbackPolling],
);
const openOAuthWindow = useCallback(
(url: string, target: ConnectTarget) => {
cleanup();
setIsWaitingAuth(true);
const oauthWindow = window.open(url, '_blank', 'width=600,height=700');
if (oauthWindow) {
oauthWindowRef.current = oauthWindow;
startWindowMonitor(oauthWindow, target);
} else {
startFallbackPolling(target);
}
},
[cleanup, startWindowMonitor, startFallbackPolling],
);
// Only LobeHub Skill OAuth signals completion via postMessage; Klavis relies on polling.
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
// Reject same-origin iframes / other tabs forging the success event.
if (event.source !== oauthWindowRef.current) return;
if (event.data?.type !== LOBEHUB_SKILL_AUTH_SUCCESS_MESSAGE) return;
const provider = event.data?.provider;
if (!provider) return;
cleanup();
void checkLobehubStatus(provider);
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [checkLobehubStatus, cleanup]);
const connect = useCallback(async () => {
if (isConnectingRef.current || isWaitingAuth) return;
const next = nextUnconnected;
if (!next) return;
isConnectingRef.current = true;
setIsConnecting(true);
try {
if (next.source === 'lobehub') {
const redirectUri = `${window.location.origin}/oauth/callback/success?provider=${encodeURIComponent(
next.provider,
)}`;
const { authorizeUrl } = await getLobehubAuth(next.provider, { redirectUri });
openOAuthWindow(authorizeUrl, next);
return;
}
const userId = useUserStore.getState().user?.id;
if (!userId) return;
const klavisType = KLAVIS_SERVER_TYPES.find((t) => t.identifier === next.provider);
if (!klavisType) return;
const newServer = await createKlavisServer({
identifier: next.provider,
serverName: klavisType.serverName,
userId,
});
if (!newServer) return;
if (newServer.isAuthenticated) {
await refreshKlavisServerTools(newServer.identifier);
} else if (newServer.oauthUrl) {
openOAuthWindow(newServer.oauthUrl, next);
}
} catch (error) {
console.error('[useSkillConnection] Failed to connect:', error);
} finally {
isConnectingRef.current = false;
setIsConnecting(false);
}
}, [
nextUnconnected,
isWaitingAuth,
getLobehubAuth,
createKlavisServer,
refreshKlavisServerTools,
openOAuthWindow,
]);
return {
connect,
isAllConnected,
isConnecting: isConnecting || isWaitingAuth,
needsConnect,
nextUnconnected,
};
};
+3 -3
View File
@@ -36,6 +36,7 @@ import setting from './setting';
import spend from './spend';
import subscription from './subscription';
import suggestQuestions from './suggestQuestions';
import taskTemplate from './taskTemplate';
import thread from './thread';
import tool from './tool';
import topic from './topic';
@@ -56,16 +57,14 @@ const resources = {
'desktop-onboarding': desktopOnboarding,
discover,
editor,
'eval': eval_,
electron,
error,
'eval': eval_,
file,
home,
hotkey,
image,
knowledgeBase,
labs,
marketAuth,
memory,
@@ -84,6 +83,7 @@ const resources = {
spend,
subscription,
suggestQuestions,
taskTemplate,
thread,
tool,
topic,
+9
View File
@@ -103,10 +103,19 @@ export default {
'finish': 'Get Started',
'interests.area.business': 'Business & Strategy',
'interests.area.coding': 'Programming & Development',
'interests.area.creator': 'Creator Economy',
'interests.area.design': 'Design & Creativity',
'interests.area.education': 'Learning & Research',
'interests.area.finance-legal': 'Finance & Legal',
'interests.area.health': 'Health & Habits',
'interests.area.hobbies': 'Hobbies & Culture',
'interests.area.hr': 'People & HR',
'interests.area.investing': 'Investing & Finance',
'interests.area.marketing': 'Marketing & Promotion',
'interests.area.operations': 'Operations & Admin',
'interests.area.other': 'Other Fields',
'interests.area.parenting': 'Family & Parenting',
'interests.area.personal': 'Personal Life',
'interests.area.product': 'Product & Management',
'interests.area.sales': 'Sales & Customer Relations',
'interests.area.writing': 'Content Creation',
+536
View File
@@ -0,0 +1,536 @@
export default {
'action.connect.button': 'Connect {{provider}}',
'action.create.error': 'Failed to create task. Please try again.',
'action.create.success': 'Scheduled task added. Find it in Lobe AI.',
'action.createButton': 'Add as scheduled task',
'action.creating': 'Creating...',
'action.dismiss.error': 'Failed to dismiss. Please try again.',
'action.dismiss.tooltip': 'Not interested',
'action.optionalConnect.button': 'Connect {{provider}} for richer results',
'schedule.daily': 'Every day at {{time}}',
'schedule.weekly': 'Every {{weekday}} at {{time}}',
'section.title': 'Try these scheduled tasks',
// ===== content-creation =====
'daily-topic-pick.title': 'Daily topic radar',
'daily-topic-pick.description':
'Each morning, scan the top 10 pieces that performed best in your niche yesterday and break down the angles.',
'daily-topic-pick.prompt':
'Every morning at 09:00, gather the 10 best-performing pieces of content from my niche yesterday, break down their angles, and pick 1-2 I could publish today.',
'hot-topic-radar.title': 'Hot topic radar',
'hot-topic-radar.description':
'Each morning, surface 5 topics heating up in your space — get in before the red ocean.',
'hot-topic-radar.prompt':
'Every morning at 10:00, surface 5 topics in my niche that are heating up but not yet saturated, with a one-line note on why each is worth jumping on now.',
'headline-inspiration.title': 'Headline inspiration',
'headline-inspiration.description':
'Each morning, 10 on-brand headline templates reverse-engineered from recent hits.',
'headline-inspiration.prompt':
'Every morning at 10:00, give me 10 headline templates matching my voice, reverse-engineered from recent viral pieces in my niche. I should be able to copy them directly when stuck.',
'viral-content-breakdown.title': 'Viral content breakdown',
'viral-content-breakdown.description':
'Each morning, deconstruct one viral piece in your space — angle, hook, structure, ending.',
'viral-content-breakdown.prompt':
'Every morning at 10:00, pick one viral piece of content from my niche and deconstruct it: angle, opening hook, structure, ending. Give me a template I can apply.',
'twitter-weekly-recap.title': 'X (Twitter) weekly recap',
'twitter-weekly-recap.description':
'Every Monday, review last week on X: best growth, worst engagement, and why.',
'twitter-weekly-recap.prompt':
'Every Monday at 10:00, recap my X (Twitter) activity from the past 7 days: top growth tweets, worst-engaging tweets, and a hypothesis for each. Suggest 3 angles to try this week.',
'youtube-weekly-recap.title': 'YouTube weekly recap',
'youtube-weekly-recap.description':
'Every Monday, pull last week of channel performance — views, CTR, retention — and flag follow-up topics.',
'youtube-weekly-recap.prompt':
'Every Monday at 09:00, pull my YouTube channel performance for the past 7 days: views, CTR, retention curves. Highlight which videos deserve a follow-up.',
'competitor-creator-tracking.title': 'Competitor creators tracking',
'competitor-creator-tracking.description':
'Tell me 3-5 creators to watch — each morning I track what they shipped and what worked.',
'competitor-creator-tracking.prompt':
'Every morning at 09:00, track the 3-5 creators I follow as competitors: what they posted, how it performed, and ideas I could adapt.',
'content-calendar-weekly.title': 'Weekly content calendar',
'content-calendar-weekly.description':
'Every Sunday night, plan next weeks 7-day publishing schedule aligned with holidays and trending moments.',
'content-calendar-weekly.prompt':
'Every Sunday at 20:00, plan next weeks 7-day publishing schedule for me: align slots with upcoming holidays and trending moments, and suggest one angle per slot. If Notion is connected, draft the schedule there.',
// ===== engineering =====
'oss-intel-daily.title': 'Open-source intel daily',
'oss-intel-daily.description':
'Each morning, 10 tech stack updates: GitHub Trending, big-name open-sourcing, key repo releases.',
'oss-intel-daily.prompt':
'Every morning at 09:00, give me 10 tech-stack updates: GitHub Trending, notable open-source releases from big companies, and new releases from repos in my stack.',
'repo-health-weekly.title': 'Repo health weekly',
'repo-health-weekly.description':
'Every Monday, review your repos: issue backlog, stalled PRs, CI failures, dependency alerts.',
'repo-health-weekly.prompt':
'Every Monday at 09:00, review the GitHub repos I maintain: issue backlog, stalled PRs, CI failures, dependency alerts. Surface what needs attention this week.',
'dependency-security-weekly.title': 'Dependency security check',
'dependency-security-weekly.description':
'Every Monday, scan your projects for vulnerabilities and outdated packages with upgrade priority.',
'dependency-security-weekly.prompt':
'Every Monday at 10:00, scan my GitHub projects for vulnerable and outdated dependencies. Suggest upgrade priority based on severity and breaking-change risk.',
'vercel-health-weekly.title': 'Vercel health weekly',
'vercel-health-weekly.description':
'Every Monday, review last week of deployments: success rate, build duration, traffic anomalies.',
'vercel-health-weekly.prompt':
'Every Monday at 10:00, recap my Vercel deployments from the past week: success rate, build duration, traffic anomalies. Flag accumulating issues.',
'linear-sprint-daily.title': 'Linear sprint daily',
'linear-sprint-daily.description':
'Each morning, sync sprint progress: blockers, overdue items, todays focus — ready before standup.',
'linear-sprint-daily.prompt':
'Every morning at 08:30, sync my Linear sprint: blockers, overdue items, what I should focus on today. Format as a 5-minute pre-standup brief.',
'tech-trend-weekly.title': 'Tech trend weekly',
'tech-trend-weekly.description':
'Every Monday, summarize key moves in frontend/backend/AI: papers, frameworks, funding.',
'tech-trend-weekly.prompt':
'Every Monday at 08:00, summarize the past week of frontend, backend, and AI movements: notable papers, framework releases, funding rounds. 10 items with one-line takeaways.',
'keyword-tech-feed.title': 'Keyword tech feed',
'keyword-tech-feed.description':
'Tell me technical keywords to track — each day I bring back 5 high-quality posts and threads.',
'keyword-tech-feed.prompt':
'Every morning at 10:00, fetch 5 high-quality new posts, blog articles, or Q&As matching my tracked technical keywords.',
// ===== design =====
'daily-design-inspiration.title': 'Daily design inspiration',
'daily-design-inspiration.description':
'Each morning, curate 10 works from Dribbble, Behance, Awwwards and Pinterest that match your style.',
'daily-design-inspiration.prompt':
'Every morning at 09:00, curate 10 design works from Dribbble, Behance, Awwwards, and Pinterest that match my style, with a short note on what makes each one stand out.',
'design-trend-weekly.title': 'Design trend weekly',
'design-trend-weekly.description':
'Every Monday, 3 trends in UI / branding / illustration with 5 representative examples.',
'design-trend-weekly.prompt':
'Every Monday at 09:00, give me 3 emerging trends across UI, branding, and illustration this week, with 5 representative examples. Help me stay current.',
'figma-files-cleanup.title': 'Figma files cleanup',
'figma-files-cleanup.description':
'Every Friday, review recently-edited Figma files — flag what to archive, what to hand off to dev.',
'figma-files-cleanup.prompt':
'Every Friday at 17:00, review my recently-edited Figma files. Flag which should be archived, which need hand-off to engineering, and which still need polish.',
'aigc-prompt-inspiration.title': 'AIGC prompt inspiration',
'aigc-prompt-inspiration.description':
'Each morning, 5 curated prompts (Midjourney / SD / Flux) sorted by style — try one today.',
'aigc-prompt-inspiration.prompt':
'Every morning at 10:00, give me 5 curated prompts for Midjourney, Stable Diffusion, or Flux, grouped by style. Each prompt should be ready to copy and try.',
'brand-watch-weekly.title': 'Brand watch weekly',
'brand-watch-weekly.description':
'Every Monday, track 10 big-brand updates — logo refresh, identity, website redesigns — with breakdown.',
'brand-watch-weekly.prompt':
'Every Monday at 10:00, track 10 brand updates from companies I follow: logo refreshes, identity changes, website redesigns. Add a one-paragraph breakdown of each.',
'font-color-weekly.title': 'Font & color weekly',
'font-color-weekly.description':
'Every Wednesday, 3 type pairings + 3 color palettes worth saving to your inspiration library.',
'font-color-weekly.prompt':
'Every Wednesday at 10:00, hand me 3 noteworthy type pairings and 3 color palettes worth saving. Include where to license each typeface.',
// ===== learning-research =====
'arxiv-curated-daily.title': 'ArXiv daily picks',
'arxiv-curated-daily.description':
'Each morning, 5 fresh arXiv papers in your research area with one-line summaries.',
'arxiv-curated-daily.prompt':
'Every morning at 09:00, pick 5 of the latest arXiv papers in my research area and give me a one-line summary for each, so I can decide which to read in depth.',
'must-read-papers-weekly.title': 'Must-read papers weekly',
'must-read-papers-weekly.description':
'Every Sunday night, 3 most-cited / most-discussed papers from this week as a deep-read list.',
'must-read-papers-weekly.prompt':
'Every Sunday at 20:00, pick the 3 papers from my research area that were most cited or most discussed this week. Curate a deep-read list I can finish over the weekend.',
'language-morning-bite.title': 'Language morning bite',
'language-morning-bite.description':
'Each morning, a 3-minute target-language read + 5 vocabulary cards. Learn during your commute.',
'language-morning-bite.prompt':
'Every morning at 07:30, give me a 3-minute reading in my target language plus 5 vocabulary cards (word, definition, example sentence).',
'industry-research-weekly.title': 'Industry research weekly',
'industry-research-weekly.description':
'Every Monday, market dynamics, funding, new players and regulatory shifts in your sector.',
'industry-research-weekly.prompt':
'Every Monday at 09:00, summarize the past week in my sector: market dynamics, funding rounds, new entrants, regulatory shifts. Format as a research brief.',
// ===== business =====
'industry-morning-brief.title': 'Industry morning brief',
'industry-morning-brief.description':
'Each morning, condense 5 important news items, funding rounds and policy shifts in your industry into a 5-minute read.',
'industry-morning-brief.prompt':
'Every morning at 08:00, condense 5 important news items, funding rounds, and policy shifts from my industry into a 5-minute read.',
'competitor-radar-daily.title': 'Competitor radar',
'competitor-radar-daily.description':
'Tell me 3-5 competitors — each day I track website updates, launches, hiring signals, social.',
'competitor-radar-daily.prompt':
'Every morning at 09:00, track 3-5 of my competitors: website changes, product launches, hiring signals, social activity. Surface what implies strategic moves.',
'funding-intel-daily.title': 'Funding intel daily',
'funding-intel-daily.description':
'Each morning, 3-5 funding announcements in your space: who raised, valuation, who led.',
'funding-intel-daily.prompt':
'Every morning at 10:00, give me 3-5 funding announcements in my space from the past 24 hours: who raised, how much, valuation if disclosed, lead investor.',
'macro-economy-weekly.title': 'Macro economy weekly',
'macro-economy-weekly.description':
'Every Monday morning, FX, rates, oil, gold, major indices — context before cross-border calls.',
'macro-economy-weekly.prompt':
'Every Monday at 08:00, give me a macro snapshot: FX rates, interest rates, oil, gold, silver, major equity indices. Add a one-paragraph "what changed" summary.',
'weekly-meeting-brief.title': 'Weekly meeting brief',
'weekly-meeting-brief.description':
'Every Monday, prep 3 talking points for your weekly strategy meeting: trends, internals, decisions.',
'weekly-meeting-brief.prompt':
'Every Monday at 08:30, prepare 3 talking points for this weeks strategy meeting: industry trends, internal metrics worth raising, and decisions that need to be made.',
// ===== marketing =====
'marketing-hot-radar.title': 'Marketing hot radar',
'marketing-hot-radar.description':
'Each morning, track 5 marketing topics heating up in your industry — which to ride, which to avoid.',
'marketing-hot-radar.prompt':
'Every morning at 10:00, track 5 marketing topics heating up in my industry, flag which ones to ride and which to avoid, with 1-2 sentence reasoning.',
'ad-creative-inspiration.title': 'Ad creative inspiration',
'ad-creative-inspiration.description':
'Each morning, scan competitor / benchmark ads (Meta / Google Ad Library) — 10 we could adapt.',
'ad-creative-inspiration.prompt':
'Every morning at 10:00, scan recent ad creative from my competitors and benchmark brands across Meta and Google Ad Library. Pick 10 worth adapting and explain why.',
'brand-mention-daily.title': 'Brand mentions daily',
'brand-mention-daily.description':
'Tell me brands / keywords to track — each evening, mention volume, sentiment, top voices.',
'brand-mention-daily.prompt':
'Every evening at 18:00, summarize todays mentions of my tracked brands and keywords on X (Twitter): volume, sentiment, top voices. Flag any unusual spikes.',
'seo-weekly-report.title': 'SEO weekly report',
'seo-weekly-report.description':
'Every Monday, ranking movement, emerging keywords, and pages worth refreshing.',
'seo-weekly-report.prompt':
'Every Monday at 09:00, give me a lightweight SEO weekly: top ranking movers (up/down), 5 emerging keywords worth targeting, and 3 existing pages ripe for a content refresh.',
'newsletter-perf-weekly.title': 'Newsletter performance weekly',
'newsletter-perf-weekly.description':
'Every Monday, open-rate, CTR, and unsubscribe trends — flag what to optimize.',
'newsletter-perf-weekly.prompt':
'Every Monday at 10:00, review my newsletter open rate, click-through rate, and unsubscribe trends from the past 4 weeks. Flag which segments need optimization.',
'kol-collab-calendar.title': 'KOL collab calendar',
'kol-collab-calendar.description':
'Every Monday, sync ongoing KOL collabs: whos due, whos overdue, performance so far.',
'kol-collab-calendar.prompt':
'Every Monday at 09:00, review the KOL collabs Im running: whos due to post, whos overdue, and performance numbers for completed posts.',
'hubspot-funnel-daily.title': 'HubSpot funnel daily',
'hubspot-funnel-daily.description':
'Each morning, track MQL / SQL / closed-won funnel changes — flag where deals are leaking.',
'hubspot-funnel-daily.prompt':
'Every morning at 09:00, review my HubSpot funnel: MQL, SQL, and closed-won movements. Highlight stages with high drop-off compared to the previous week.',
// ===== product =====
'user-feedback-daily.title': 'User feedback daily',
'user-feedback-daily.description':
'Each morning, aggregate feedback from all channels (stores, social, support) into top 20 items, sorted by sentiment and theme.',
'user-feedback-daily.prompt':
'Every morning at 09:00, aggregate user feedback from all channels (app stores, social media, customer support) into the top 20 items, sorted by sentiment and theme.',
'competitor-update-daily.title': 'Competitor product updates',
'competitor-update-daily.description':
'Tell me 3-5 competitors — each day I check changelogs, new features and website changes.',
'competitor-update-daily.prompt':
'Every morning at 10:00, monitor 3-5 competitor products: changelogs, new features, website copy changes. Flag any signal worth a deeper look.',
'standup-brief.title': 'Standup brief',
'standup-brief.description':
'Each morning before standup, pull a Linear progress brief: todays focus, blockers, yesterday done.',
'standup-brief.prompt':
'Every morning at 08:30, pull a Linear progress brief: todays focus, blockers, what I closed yesterday. Format as 3 bullets ready to read aloud at standup.',
'iteration-recap-weekly.title': 'Iteration recap weekly',
'iteration-recap-weekly.description':
'Every Friday afternoon, this iterations data: completion rate, overdue items, new bugs.',
'iteration-recap-weekly.prompt':
'Every Friday at 17:00, recap this weeks iteration: completion rate, overdue items, new bugs filed. Format ready to drop into Mondays retrospective.',
'core-metric-daily.title': 'Core metrics daily',
'core-metric-daily.description':
'Tell me which metrics to watch (DAU, retention, conversion) — each morning I sync the deltas.',
'core-metric-daily.prompt':
'Every morning at 09:00, sync the changes in my core metrics (DAU, retention, conversion). Compare against yesterday and the 7-day average.',
'user-interview-schedule.title': 'User interview prep',
'user-interview-schedule.description':
'Every Monday, walk through this weeks interviews: who, when, are the questions ready.',
'user-interview-schedule.prompt':
'Every Monday at 09:00, list this weeks scheduled user interviews: participant name, time, prep checklist (questions ready, recording set up).',
'prd-review-reminder.title': 'PRD review reminder',
'prd-review-reminder.description':
'Every Friday, list PRDs due for review this week — dont leave docs stuck in draft.',
'prd-review-reminder.prompt':
'Every Friday at 15:00, review the PRDs and decision docs in my Notion that are due for review this week. Flag anything still stuck in draft.',
// ===== sales-customer =====
'daily-followup-list.title': 'Daily follow-up list',
'daily-followup-list.description':
'Each morning, prioritized list of customers to follow up with today, with last-touch context.',
'daily-followup-list.prompt':
'Every morning at 09:00, build a prioritized follow-up list for today from my HubSpot contacts. For each, summarize the last interaction.',
'renewal-risk-weekly.title': 'Renewal risk weekly',
'renewal-risk-weekly.description':
'Every Monday, flag this months renewals — especially accounts with declining usage.',
'renewal-risk-weekly.prompt':
'Every Monday at 09:00, review HubSpot contracts expiring this month and flag accounts with declining usage. Suggest a save play for each at-risk account.',
'deal-pipeline-weekly.title': 'Deal pipeline weekly',
'deal-pipeline-weekly.description':
'Every Friday, every deal in pipeline: moving, stalled, expected close this month.',
'deal-pipeline-weekly.prompt':
'Every Friday at 16:00, review every deal in my HubSpot pipeline: which moved this week, which stalled, and projected close-won by end of month.',
'key-account-radar.title': 'Key account radar',
'key-account-radar.description':
'Tell me your key accounts — each day I track their news, funding, exec changes.',
'key-account-radar.prompt':
'Every morning at 09:00, scan news on my key accounts: company news, funding, executive changes. Surface anything I could use as a renewal conversation hook.',
'zendesk-ticket-daily.title': 'Zendesk ticket daily',
'zendesk-ticket-daily.description':
'Each morning, Zendesk snapshot: backlog size, SLA breaches, top recurring issues.',
'zendesk-ticket-daily.prompt':
'Every morning at 09:00, give me a Zendesk snapshot: open ticket backlog, SLA breaches, and the top 3 recurring issues from the past 24 hours.',
// ===== operations =====
'morning-brief.title': 'Morning brief',
'morning-brief.description':
'Every day at 8: todays schedule, pending email count, todos, weather. Read on the way in.',
'morning-brief.prompt':
'Every morning at 08:00, send me: todays calendar, pending email count, top 3 todos, and weather. Format as a 1-minute read.',
'meeting-brief.title': 'Meeting prep brief',
'meeting-brief.description':
'Each morning, prep 1-page brief for every meeting today: context, attendees, last notes.',
'meeting-brief.prompt':
'Every morning at 08:30, generate a 1-page prep brief for every meeting on my calendar today: context, attendees, last meeting notes. Read before walking in.',
'calendar-conflict-check.title': 'Calendar conflict check',
'calendar-conflict-check.description':
'Each morning, scan today for conflicts, back-to-back meetings, insufficient travel time.',
'calendar-conflict-check.prompt':
'Every morning at 07:30, scan todays calendar for conflicts, back-to-back meetings, or insufficient travel/buffer time. Suggest fixes.',
'friday-wrap-list.title': 'Friday wrap list',
'friday-wrap-list.description':
'Every Friday afternoon: what didnt finish, what ships Monday, the first thing for next week.',
'friday-wrap-list.prompt':
'Every Friday at 16:00, list: what I didnt finish this week, what needs to ship Monday, and the first thing I should pick up next week.',
// ===== hr =====
'recruit-funnel-daily.title': 'Recruit funnel daily',
'recruit-funnel-daily.description':
'Each morning, candidates per role: new applications, awaiting interview, awaiting feedback.',
'recruit-funnel-daily.prompt':
'Every morning at 09:00, summarize the recruit funnel by role: new applications, candidates awaiting interview, candidates awaiting feedback. Flag interviewers who are blocking.',
'onboarding-buddy-weekly.title': 'New hire onboarding',
'onboarding-buddy-weekly.description':
'Every Monday, new hires within 90 days: progress, buddy feedback, what to focus on.',
'onboarding-buddy-weekly.prompt':
'Every Monday at 09:00, generate a progress update for each new hire still within their first 90 days: tasks completed, buddy feedback, what they should focus on this week.',
'team-status-weekly.title': 'Team status weekly',
'team-status-weekly.description':
'Every Monday, team PTO, overtime, meeting load trends — early warning for burnout.',
'team-status-weekly.prompt':
'Every Monday at 09:00, review the teams past week: PTO, overtime hours, meeting load. Flag anyone trending toward burnout.',
// ===== finance-legal =====
'precious-metals-daily.title': 'Metals & energy daily',
'precious-metals-daily.description':
'Each market close, gold, silver, copper and oil prices with day change — flag big moves.',
'precious-metals-daily.prompt':
'Every day at 16:00 (after close), give me prices and day-over-day change for gold, silver, copper, and oil. Flag any move above 2%.',
'pre-market-brief.title': 'Pre-market brief',
'pre-market-brief.description':
'Each morning before open, macro headlines, key earnings, news on companies you hold.',
'pre-market-brief.prompt':
'Every morning at 09:00, give me a pre-market brief: macro headlines, key earnings released today, and news on the companies in my portfolio.',
'cashflow-weekly.title': 'Cashflow weekly',
'cashflow-weekly.description':
'Every Monday, whats coming in this week, whats going out, large expenses next week.',
'cashflow-weekly.prompt':
'Every Monday at 09:00, review cashflow: receivables due this week, payables due, and large expenses scheduled for next week.',
'contract-expiry-weekly.title': 'Contract expiry weekly',
'contract-expiry-weekly.description':
'Every Monday, contracts expiring next month (subscriptions, leases, partnerships).',
'contract-expiry-weekly.prompt':
'Every Monday at 09:00, list contracts (subscriptions, leases, partnerships) expiring in the next 30 days. Flag which to renew, which to cancel.',
'regulation-watch-weekly.title': 'Regulation watch weekly',
'regulation-watch-weekly.description':
'Tell me your compliance areas (data, tax, labor) — every Monday a change summary with impact.',
'regulation-watch-weekly.prompt':
'Every Monday at 10:00, summarize regulatory changes in my tracked compliance areas (data, tax, labor) from the past week. For each, judge the impact on us.',
'invoice-collection-daily.title': 'Invoice collection daily',
'invoice-collection-daily.description':
'Each morning, overdue invoices, days overdue, who needs a chase email today.',
'invoice-collection-daily.prompt':
'Every morning at 10:00, list overdue invoices with days overdue and the contact to chase. Draft a polite chase email for each.',
// ===== creator =====
'cross-platform-engagement-daily.title': 'Cross-platform engagement',
'cross-platform-engagement-daily.description':
'Each morning, comments, DMs, mentions and new followers across all platforms — 30 seconds.',
'cross-platform-engagement-daily.prompt':
'Every morning at 09:00, aggregate comments, DMs, mentions, and new followers across my platforms. Highlight the 5 worth replying to.',
'brand-collab-weekly.title': 'Brand collab weekly',
'brand-collab-weekly.description':
'Every Monday, scan brands actively recruiting creators — match by niche and audience size.',
'brand-collab-weekly.prompt':
'Every Monday at 10:00, scan brands and public calls actively looking for creators. Match against my niche and audience size. Surface 5 worth applying.',
'follower-growth-weekly.title': 'Follower growth weekly',
'follower-growth-weekly.description':
'Every Monday, follower changes across platforms — where to double down, where to fix.',
'follower-growth-weekly.prompt':
'Every Monday at 10:00, review follower growth across my X (Twitter) and other platforms. Surface where to double down and where engagement is dropping.',
'youtube-channel-weekly.title': 'YouTube channel weekly',
'youtube-channel-weekly.description':
'Every Monday, channel stats: subscribers, top videos, audience retention, revenue.',
'youtube-channel-weekly.prompt':
'Every Monday at 09:00, pull my YouTube channel stats: subscriber change, top performing videos, audience retention, revenue movement.',
'monetization-opportunity-weekly.title': 'Monetization opportunities',
'monetization-opportunity-weekly.description':
'Every Wednesday, new monetization channels and case studies for creators: ads, courses, memberships, commerce.',
'monetization-opportunity-weekly.prompt':
'Every Wednesday at 10:00, surface new monetization channels and case studies relevant to creators in my niche: sponsorships, paid content, memberships, commerce.',
// ===== investing =====
'portfolio-daily.title': 'Portfolio daily',
'portfolio-daily.description':
'Tell me your holdings — each market close, day change, key news, holding-company updates.',
'portfolio-daily.prompt':
'Every day at 16:00 (after close), give me my portfolio update: per-position day change, top news that affects each holding, and any company-specific announcements.',
'crypto-market-daily.title': 'Crypto market daily',
'crypto-market-daily.description':
'Each morning, BTC, ETH and your tracked tokens 24h change + key on-chain events.',
'crypto-market-daily.prompt':
'Every morning at 09:00, give me 24h price change for BTC, ETH, and my tracked tokens, plus the most important on-chain events from the past day.',
// ===== parenting =====
'child-growth-weekly.title': 'Child growth weekly',
'child-growth-weekly.description':
'Tell me your childs age — every Monday, this weeks development focus + activity ideas.',
'child-growth-weekly.prompt':
'Every Monday at 09:00, give me development focus areas appropriate for my childs age this week, plus parent-child activity ideas and things to watch for.',
'child-study-weekly.title': 'Child study weekly',
'child-study-weekly.description':
'Tell me what your child is studying — every Sunday, this weeks progress + next weeks focus.',
'child-study-weekly.prompt':
'Every Sunday at 20:00, recap my childs study progress this week and identify focus areas for next week. Suggest practice activities for each subject.',
'family-finance-weekly.title': 'Family finance weekly',
'family-finance-weekly.description':
'Every Sunday night, this weeks spending breakdown, budget completion, next weeks big expenses.',
'family-finance-weekly.prompt':
'Every Sunday at 20:00, review this weeks family spending: category breakdown from my Google Sheets log, budget completion, and large planned expenses next week.',
'family-task-schedule.title': 'Family task schedule',
'family-task-schedule.description':
'Every Monday morning, divvy up this weeks chores, errands, school runs, and bills across the family.',
'family-task-schedule.prompt':
'Every Monday at 08:00, draft this weeks family task plan: chores, grocery runs, school pickups, bill payments. Assign tentative owners and time slots. If Google Calendar is connected, propose blocks I can drop in.',
// ===== health =====
'diet-log-companion.title': 'Diet log companion',
'diet-log-companion.description':
'Each evening, walk through what you ate today — kind suggestions, no judgment.',
'diet-log-companion.prompt':
'Every evening at 21:00, walk me through what I ate today and offer one or two kind, non-judgmental suggestions for tomorrow.',
// ===== hobbies =====
'podcast-new-episodes.title': 'Podcast new episodes',
'podcast-new-episodes.description':
'Tell me your subscribed podcasts — every Monday, this weeks new episodes + 3 worth listening.',
'podcast-new-episodes.prompt':
'Every Monday at 09:00, list new episodes from my subscribed podcasts this week, and recommend the top 3 worth listening to first.',
'newsletter-aggregator.title': 'Newsletter aggregator',
'newsletter-aggregator.description':
'Every Sunday night, merge your subscribed newsletters into one weekend digest.',
'newsletter-aggregator.prompt':
'Every Sunday at 20:00, scan my Gmail inbox for newsletters received this week and merge them into one weekend digest grouped by theme.',
'series-update-weekly.title': 'Series & books weekly',
'series-update-weekly.description':
'Tell me what youre following — each week, episode/chapter updates and quick recaps.',
'series-update-weekly.prompt':
'Every Monday at 09:00, give me update notices and a short recap for the shows, novels, or comics Im following.',
'travel-inspiration-weekly.title': 'Travel inspiration weekly',
'travel-inspiration-weekly.description':
'Every Wednesday, target-city flight prices, visa policy, best travel windows.',
'travel-inspiration-weekly.prompt':
'Every Wednesday at 10:00, give me flight price changes, visa policy updates, and best travel windows for the cities on my wishlist.',
'watchlist-friday.title': 'Watchlist Friday',
'watchlist-friday.description':
'Every Friday, 5 highly-rated new releases this week (Douban / IMDb) with one-line reviews.',
'watchlist-friday.prompt':
'Every Friday at 18:00, pick 5 highly-rated new film/series releases this week from Douban and IMDb. Add a one-line review for each.',
'exhibition-event-weekly.title': 'Exhibitions & events',
'exhibition-event-weekly.description':
'Tell me your city — every Monday, this weeks exhibitions, performances, and live shows.',
'exhibition-event-weekly.prompt':
'Every Monday at 10:00, list this weeks exhibitions, performances, and livehouse shows in my city. Add quick context for the most interesting ones.',
// ===== personal-life =====
'daily-learning-bite.title': 'Daily learning bite',
'daily-learning-bite.description':
'Each morning, deliver one 15-minute curated piece (article, video, or podcast) in your learning area.',
'daily-learning-bite.prompt':
'Every morning at 07:30, bring me one 15-minute curated piece (article, video, or podcast) in my learning area, with a quick takeaway.',
'sunday-reflection.title': 'Sunday reflection',
'sunday-reflection.description':
'Every Sunday night, walk through 5 questions: best moment, frustrations, top 3 for next week.',
'sunday-reflection.prompt':
'Every Sunday at 21:00, walk me through 5 reflection prompts: most fulfilling thing this week, most frustrating, top 3 priorities for next week, what I learned, what I should drop.',
'morning-ritual.title': 'Morning ritual',
'morning-ritual.description':
'Every day at 7: weather, todays schedule, a thought-of-the-day, and a movement nudge — a gentle start.',
'morning-ritual.prompt':
'Every morning at 07:00, send me a gentle morning ritual: weather, todays schedule, one short thought-of-the-day, and a small movement suggestion. If Google Calendar is connected, anchor the schedule there.',
'bedtime-gratitude.title': 'Bedtime gratitude',
'bedtime-gratitude.description':
'Every night at 22, prompt 3 things youre grateful for and one thing you learned today.',
'bedtime-gratitude.prompt':
'Every evening at 22:00, ask me to share 3 things Im grateful for today and one thing I learned. Return a gentle one-paragraph reflection. If Notion is connected, append the entry to my journal page.',
};
@@ -1,7 +1,3 @@
import { type Dayjs } from 'dayjs';
export type ScheduleType = 'daily' | 'hourly' | 'weekly';
// Schedule type options
export const SCHEDULE_TYPE_OPTIONS = [
{ label: 'agentCronJobs.scheduleType.daily', value: 'daily' },
@@ -80,99 +76,3 @@ export const WEEKDAY_LABELS: Record<number, string> = {
5: 'agentCronJobs.weekday.friday',
6: 'agentCronJobs.weekday.saturday',
};
/**
* Parse cron pattern to extract schedule info
* Format: minute hour day month weekday
*/
export const parseCronPattern = (
cronPattern: string,
): {
hourlyInterval?: number;
scheduleType: ScheduleType;
triggerHour: number;
triggerMinute: number;
weekdays?: number[];
} => {
const parts = cronPattern.split(' ');
if (parts.length !== 5) {
return { scheduleType: 'daily', triggerHour: 0, triggerMinute: 0 };
}
const [minute, hour, , , weekday] = parts;
const rawMinute = minute === '*' ? 0 : Number.parseInt(minute);
// Normalize to nearest 30-minute interval (0 or 30)
const triggerMinute = rawMinute >= 15 && rawMinute < 45 ? 30 : 0;
// Hourly: 0 * * * * or 0 */N * * *
if (hour.startsWith('*/')) {
const interval = Number.parseInt(hour.slice(2));
return {
hourlyInterval: interval,
scheduleType: 'hourly',
triggerHour: 0,
triggerMinute,
};
}
if (hour === '*') {
return {
hourlyInterval: 1,
scheduleType: 'hourly',
triggerHour: 0,
triggerMinute,
};
}
const triggerHour = Number.parseInt(hour);
// Weekly: has specific weekday(s)
if (weekday !== '*') {
const weekdays = weekday.split(',').map((d) => Number.parseInt(d));
return {
scheduleType: 'weekly',
triggerHour,
triggerMinute,
weekdays,
};
}
// Daily: specific hour, any weekday
return {
scheduleType: 'daily',
triggerHour,
triggerMinute,
};
};
/**
* Build cron pattern from schedule info
* Format: minute hour day month weekday
*/
export const buildCronPattern = (
scheduleType: ScheduleType,
triggerTime: Dayjs,
hourlyInterval?: number,
weekdays?: number[],
): string => {
const rawMinute = triggerTime.minute();
// Normalize to 0 or 30
const minute = rawMinute >= 15 && rawMinute < 45 ? 30 : 0;
const hour = triggerTime.hour();
switch (scheduleType) {
case 'hourly': {
const interval = hourlyInterval || 1;
if (interval === 1) {
return `${minute} * * * *`;
}
return `${minute} */${interval} * * *`;
}
case 'daily': {
return `${minute} ${hour} * * *`;
}
case 'weekly': {
const days = weekdays && weekdays.length > 0 ? weekdays.sort().join(',') : '0,1,2,3,4,5,6';
return `${minute} ${hour} * * ${days}`;
}
}
};
@@ -1,5 +1,6 @@
'use client';
import { type ScheduleType } from '@lobechat/utils/cron';
import { Checkbox, Flexbox, FormGroup, Text } from '@lobehub/ui';
import { Select } from '@lobehub/ui/base-ui';
import { Divider, InputNumber, TimePicker } from 'antd';
@@ -9,7 +10,6 @@ import dayjs from 'dayjs';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { type ScheduleType } from '../CronConfig';
import { SCHEDULE_TYPE_OPTIONS, TIMEZONE_OPTIONS } from '../CronConfig';
const styles = createStaticStyles(({ css, cssVar }) => ({
@@ -1,6 +1,7 @@
'use client';
import { EDITOR_DEBOUNCE_TIME } from '@lobechat/const';
import { buildCronPattern, parseCronPattern, type ScheduleType } from '@lobechat/utils/cron';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { useDebounceFn } from 'ahooks';
import { App, Empty, message } from 'antd';
@@ -28,8 +29,6 @@ import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfi
import { useUserStore } from '@/store/user';
import { labPreferSelectors } from '@/store/user/selectors';
import { type ScheduleType } from './CronConfig';
import { buildCronPattern, parseCronPattern } from './CronConfig';
import CronJobContentEditor from './features/CronJobContentEditor';
import CronJobHeader from './features/CronJobHeader';
import CronJobSaveButton from './features/CronJobSaveButton';
+3 -1
View File
@@ -4,6 +4,7 @@ import { Flexbox } from '@lobehub/ui';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import RecommendTaskTemplates from '@/business/client/RecommendTaskTemplates';
import DailyBrief from '@/features/DailyBrief';
import { useHomeStore } from '@/store/home';
import { useUserStore } from '@/store/user';
@@ -31,8 +32,9 @@ const Home = memo(() => {
<Welcome />
<InputArea />
{isLogin && (
<Flexbox style={{ display: hideOtherModules ? 'none' : undefined }}>
<Flexbox gap={40} style={{ display: hideOtherModules ? 'none' : undefined }}>
<DailyBrief />
<RecommendTaskTemplates />
</Flexbox>
)}
{/* Use CSS visibility to hide instead of unmounting to prevent data re-fetching */}
+18
View File
@@ -1,12 +1,21 @@
import {
BabyIcon,
CameraIcon,
ChartNetworkIcon,
CodeXmlIcon,
CompassIcon,
GraduationCapIcon,
HandCoinsIcon,
HeartIcon,
HomeIcon,
LineChartIcon,
PaintBucketIcon,
PenIcon,
PercentIcon,
ScaleIcon,
SettingsIcon,
TargetIcon,
UsersIcon,
} from 'lucide-react';
/** Default target when the user opens `/onboarding`. Flip to `'agent'` when agent onboarding is ready to ship as the primary flow. */
@@ -32,6 +41,15 @@ export const INTEREST_AREAS = [
{ icon: PercentIcon, key: 'marketing' },
{ icon: TargetIcon, key: 'product' },
{ icon: HandCoinsIcon, key: 'sales' },
{ icon: SettingsIcon, key: 'operations' },
{ icon: UsersIcon, key: 'hr' },
{ icon: ScaleIcon, key: 'finance-legal' },
{ icon: CameraIcon, key: 'creator' },
{ icon: LineChartIcon, key: 'investing' },
{ icon: BabyIcon, key: 'parenting' },
{ icon: HeartIcon, key: 'health' },
{ icon: CompassIcon, key: 'hobbies' },
{ icon: HomeIcon, key: 'personal' },
] as const;
export type InterestAreaKey = (typeof INTEREST_AREAS)[number]['key'];
+8 -1
View File
@@ -29,6 +29,10 @@ const batchUpdateStatusSchema = z.object({
// Create input schema for tRPC that omits server-managed fields
const createAgentCronJobInputSchema = InsertAgentCronJobSchema.omit({
userId: true, // Provided by authentication context
}).extend({
// Optional — when set, the cron job is being created from a recommended task template;
// the server records it so the same template is excluded from future recommendations.
templateId: z.string().max(64).optional(),
});
/**
@@ -75,8 +79,11 @@ export const agentCronJobRouter = router({
try {
const cronJobModel = new AgentCronJobModel(db, userId);
// `templateId` is accepted for analytics/cloud bookkeeping but not persisted
// on the cron job itself.
const { templateId: _templateId, ...cronJobInput } = input;
// Add userId to the input data since it's provided by authentication context
const cronJobData = { ...input, userId };
const cronJobData = { ...cronJobInput, userId };
const cronJob = await cronJobModel.create(cronJobData as InsertAgentCronJob);
return {
+2
View File
@@ -5,6 +5,7 @@ import { accountDeletionRouter } from '@/business/server/lambda-routers/accountD
import { referralRouter } from '@/business/server/lambda-routers/referral';
import { spendRouter } from '@/business/server/lambda-routers/spend';
import { subscriptionRouter } from '@/business/server/lambda-routers/subscription';
import { taskTemplateRouter } from '@/business/server/lambda-routers/taskTemplate';
import { topUpRouter } from '@/business/server/lambda-routers/topUp';
import { publicProcedure, router } from '@/libs/trpc/lambda';
@@ -123,6 +124,7 @@ export const lambdaRouter = router({
referral: referralRouter,
spend: spendRouter,
subscription: subscriptionRouter,
taskTemplate: taskTemplateRouter,
topUp: topUpRouter,
});
@@ -0,0 +1,162 @@
// @vitest-environment node
import { type TaskTemplate, taskTemplates } from '@lobechat/const';
import { describe, expect, it } from 'vitest';
import { isTemplateSkillSourceEligible, RECOMMEND_COUNT, TaskTemplateService } from './index';
const makeTemplate = (overrides: Partial<TaskTemplate>): TaskTemplate => ({
category: 'engineering',
cronPattern: '0 9 * * *',
id: 't',
interests: [],
...overrides,
});
const UTC_DAY_1 = new Date('2026-04-24T10:00:00Z');
const UTC_DAY_2 = new Date('2026-04-25T10:00:00Z');
describe('TaskTemplateService.listDailyRecommend', () => {
it('returns RECOMMEND_COUNT items when user has matching interests', async () => {
const service = new TaskTemplateService('user-1');
const picked = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
expect(picked).toHaveLength(RECOMMEND_COUNT);
const codingMatches = taskTemplates.filter((t) => t.interests.includes('coding'));
expect(picked.some((p) => codingMatches.some((m) => m.id === p.id))).toBe(true);
});
it('is stable for the same (userId, utcDate)', async () => {
const service = new TaskTemplateService('user-1');
const a = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
const b = await service.listDailyRecommend(['coding'], {
now: new Date('2026-04-24T23:59:00Z'), // still same UTC day
});
expect(a.map((t) => t.id)).toEqual(b.map((t) => t.id));
});
it('changes across UTC days', async () => {
let matches = 0;
for (const suffix of ['a', 'b', 'c', 'd', 'e']) {
const service = new TaskTemplateService(`user-${suffix}`);
const d1 = await service.listDailyRecommend([], { now: UTC_DAY_1 });
const d2 = await service.listDailyRecommend([], { now: UTC_DAY_2 });
if (JSON.stringify(d1) === JSON.stringify(d2)) matches += 1;
}
expect(matches).toBeLessThan(5);
});
it('differs across users on the same day', async () => {
const results = await Promise.all(
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((s) =>
new TaskTemplateService(`user-${s}`)
.listDailyRecommend([], { now: UTC_DAY_1 })
.then((r) => r.map((t) => t.id).join(',')),
),
);
expect(new Set(results).size).toBeGreaterThan(1);
});
it('falls back to fallback categories when user has no interests', async () => {
const service = new TaskTemplateService('user-1');
const picked = await service.listDailyRecommend([], { now: UTC_DAY_1 });
expect(picked).toHaveLength(RECOMMEND_COUNT);
for (const p of picked) {
expect(taskTemplates.some((t) => t.id === p.id)).toBe(true);
}
});
it('intersection is case-insensitive and trims whitespace', async () => {
const service = new TaskTemplateService('user-1');
const picked = await service.listDailyRecommend([' CoDing '], { now: UTC_DAY_1 });
const codingMatches = taskTemplates.filter((t) => t.interests.includes('coding'));
expect(picked.some((p) => codingMatches.some((m) => m.id === p.id))).toBe(true);
});
it('unrecognized interest strings fall back to non-matched pool', async () => {
const service = new TaskTemplateService('user-1');
// Freeform custom input won't match any template's interests — should still return 3 picks
const picked = await service.listDailyRecommend(['my special hobby'], { now: UTC_DAY_1 });
expect(picked).toHaveLength(RECOMMEND_COUNT);
});
it('excludes templates listed in excludeIds', async () => {
const service = new TaskTemplateService('user-1');
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
expect(baseline.length).toBeGreaterThan(0);
const excludedId = baseline[0].id;
const picked = await service.listDailyRecommend(['coding'], {
excludeIds: [excludedId],
now: UTC_DAY_1,
});
expect(picked.some((t) => t.id === excludedId)).toBe(false);
expect(picked).toHaveLength(RECOMMEND_COUNT);
});
it('drops templates whose required skill sources are not all enabled', async () => {
const service = new TaskTemplateService('user-1');
// Without `enabledSkillSources`, any template with `requiresSkills` is filtered out.
// Since current catalog has none, this should match the baseline (no-op).
const baseline = await service.listDailyRecommend(['coding'], { now: UTC_DAY_1 });
expect(baseline).toHaveLength(RECOMMEND_COUNT);
});
it('returns only non-excluded templates when most are excluded', async () => {
const service = new TaskTemplateService('user-1');
const allIds = taskTemplates.map((t) => t.id);
const keepIds = allIds.slice(0, 2);
const excludeIds = allIds.slice(2);
const picked = await service.listDailyRecommend(['coding'], {
excludeIds,
now: UTC_DAY_1,
});
expect(picked.map((t) => t.id).sort()).toEqual([...keepIds].sort());
});
});
describe('isTemplateSkillSourceEligible', () => {
it('treats templates without requiresSkills as always eligible', () => {
expect(isTemplateSkillSourceEligible(makeTemplate({}))).toBe(true);
expect(isTemplateSkillSourceEligible(makeTemplate({}), new Set())).toBe(true);
});
it('filters out skill-dependent templates when enabledSkillSources is undefined', () => {
const t = makeTemplate({ requiresSkills: [{ provider: 'github', source: 'lobehub' }] });
expect(isTemplateSkillSourceEligible(t, undefined)).toBe(false);
});
it('keeps templates whose only source is enabled', () => {
const t = makeTemplate({ requiresSkills: [{ provider: 'notion', source: 'klavis' }] });
expect(isTemplateSkillSourceEligible(t, new Set(['klavis']))).toBe(true);
});
it('drops templates whose source is not in enabledSkillSources', () => {
const t = makeTemplate({ requiresSkills: [{ provider: 'notion', source: 'klavis' }] });
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(false);
});
it('requires every source for multi-skill templates', () => {
const t = makeTemplate({
requiresSkills: [
{ provider: 'github', source: 'lobehub' },
{ provider: 'notion', source: 'klavis' },
],
});
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(false);
expect(isTemplateSkillSourceEligible(t, new Set(['klavis']))).toBe(false);
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub', 'klavis']))).toBe(true);
});
it('treats empty requiresSkills array same as undefined (always eligible)', () => {
const t = makeTemplate({ requiresSkills: [] });
expect(isTemplateSkillSourceEligible(t, undefined)).toBe(true);
});
});
+101
View File
@@ -0,0 +1,101 @@
import {
TASK_TEMPLATE_FALLBACK_CATEGORIES,
type TaskTemplate,
taskTemplates,
type TaskTemplateSkillSource,
} from '@lobechat/const';
export const RECOMMEND_COUNT = 3;
const hashString = (str: string): number => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return hash >>> 0;
};
/** mulberry32 — pure function of seed, used so recommendations are stable per user/day. */
const mulberry32 = (seed: number) => {
let t = seed >>> 0;
return () => {
t = (t + 0x6d_2b_79_f5) | 0;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4_294_967_296;
};
};
const seededShuffle = <T>(items: T[], seed: number): T[] => {
const arr = [...items];
const rand = mulberry32(seed);
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
};
const normalize = (s: string) => s.trim().toLowerCase();
const hasIntersection = (template: TaskTemplate, userInterests: string[]): boolean => {
if (userInterests.length === 0) return false;
const normalized = new Set(userInterests.map(normalize));
return template.interests.some((i) => normalized.has(normalize(i)));
};
const getUtcDateStr = (now: Date): string => now.toISOString().slice(0, 10);
/**
* A template is eligible only if every `requiresSkills[].source` is enabled
* server-side. When a template declares no skill requirement, it is always
* eligible. When the caller passes no `enabledSkillSources` set, any template
* with skill requirements is filtered out (conservative default).
*/
export const isTemplateSkillSourceEligible = (
template: TaskTemplate,
enabledSkillSources?: ReadonlySet<TaskTemplateSkillSource>,
): boolean => {
if (!template.requiresSkills || template.requiresSkills.length === 0) return true;
if (!enabledSkillSources) return false;
return template.requiresSkills.every((s) => enabledSkillSources.has(s.source));
};
export class TaskTemplateService {
constructor(private userId: string) {}
/**
* Client resolves user.interests (localized labels or raw values) to
* INTEREST_AREAS keys before calling — see useResolvedInterestKeys in the UI.
*/
async listDailyRecommend(
interestKeys: string[],
options: {
enabledSkillSources?: ReadonlySet<TaskTemplateSkillSource>;
excludeIds?: string[];
now?: Date;
} = {},
): Promise<TaskTemplate[]> {
const { enabledSkillSources, excludeIds, now = new Date() } = options;
const excluded = new Set(excludeIds ?? []);
const seed = hashString(`${this.userId}:${getUtcDateStr(now)}`);
const candidates = taskTemplates.filter(
(t) => !excluded.has(t.id) && isTemplateSkillSourceEligible(t, enabledSkillSources),
);
const matched = candidates.filter((t) => hasIntersection(t, interestKeys));
const result: TaskTemplate[] = seededShuffle(matched, seed).slice(0, RECOMMEND_COUNT);
const takeFrom = (pool: TaskTemplate[]) => {
if (result.length >= RECOMMEND_COUNT) return;
const seen = new Set(result.map((t) => t.id));
const remaining = pool.filter((t) => !seen.has(t.id));
result.push(...seededShuffle(remaining, seed).slice(0, RECOMMEND_COUNT - result.length));
};
takeFrom(candidates.filter((t) => TASK_TEMPLATE_FALLBACK_CATEGORIES.includes(t.category)));
takeFrom(candidates);
return result;
}
}
+4 -1
View File
@@ -13,8 +13,11 @@ import { lambdaClient } from '@/libs/trpc/client/lambda';
class AgentCronJobService {
/**
* Create a new cron job
*
* `templateId` is optional — when set, server records the task template
* interaction so the same template is excluded from future recommendations.
*/
async create(data: Omit<CreateAgentCronJobData, 'userId'>) {
async create(data: Omit<CreateAgentCronJobData, 'userId'> & { templateId?: string }) {
return await lambdaClient.agentCronJob.create.mutate(data);
}