Compare commits

..

1 Commits

Author SHA1 Message Date
Innei 5f7a510c5e 💄 style(intervention): polish confirmation bar layout 2026-05-09 21:59:35 +08:00
98 changed files with 307 additions and 3780 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ RUN set -e && \
pnpm i && \
mkdir -p /deps && \
cd /deps && \
echo '{"name":"deps","private":true}' > package.json && \
pnpm init && \
pnpm add pg drizzle-orm
COPY . .
-4
View File
@@ -184,10 +184,6 @@
"groupWizard.searchTemplates": "Search templates...",
"groupWizard.title": "Create Group",
"groupWizard.useTemplate": "Use Template",
"heteroAgent.cloudRepo.multiSelected": "{{count}} repos selected",
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
"heteroAgent.cloudRepo.notSet": "No repo selected",
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
"heteroAgent.fullAccess.label": "Full access",
"heteroAgent.fullAccess.tooltip": "Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.",
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
+1 -1
View File
@@ -29,7 +29,7 @@
"batchDelete": "Batch Delete",
"blog": "Product Blog",
"botIntegrationBanner.dismiss": "Dismiss",
"botIntegrationBanner.title": "Talk to Lobe AI on your favorite messaging apps.",
"botIntegrationBanner.title": "Add Channels to Lobe AI",
"branching": "Create Subtopic",
"branchingDisable": "The \"Sub-topic\" feature is unavailable in the current mode. To use this feature, please switch to Postgres/Pglite DB mode or use LobeHub Cloud.",
"branchingRequiresSavedTopic": "Current topic is not saved, please save it first to use subtopic feature",
-12
View File
@@ -40,18 +40,6 @@
"modifier.acceptAll": "Keep All",
"modifier.reject": "Revert",
"modifier.rejectAll": "Revert All",
"skillFrontmatter.edit": "Edit metadata",
"skillFrontmatter.empty": "No metadata",
"skillFrontmatter.invalid.descriptionInvalid": "Description must be single-line text.",
"skillFrontmatter.invalid.descriptionRequired": "Description is required.",
"skillFrontmatter.invalid.mapping": "Frontmatter must be a YAML mapping.",
"skillFrontmatter.invalid.nameInvalid": "Name must use lowercase letters, numbers, and hyphens.",
"skillFrontmatter.invalid.nameLocked": "Name must remain {{name}}. Rename the skill bundle instead.",
"skillFrontmatter.invalid.nameRequired": "Name is required.",
"skillFrontmatter.invalid.required": "Frontmatter is required.",
"skillFrontmatter.invalid.syntax": "Invalid YAML syntax.",
"skillFrontmatter.saveFailed": "Metadata was not saved. Retry, or keep editing.",
"skillFrontmatter.title": "Skill metadata",
"slash.compact": "Compact context",
"slash.h1": "Heading 1",
"slash.h2": "Heading 2",
+1 -1
View File
@@ -681,7 +681,7 @@
"skillDetail.tools": "Tools",
"skillDetail.trustWarning": "Only use connectors from developers you trust. LobeHub does not control which tools developers make available and cannot verify that they will work as intended or that they won't change.",
"skillInstallBanner.dismiss": "Dismiss",
"skillInstallBanner.title": "Connect your favorite apps to Lobe AI.",
"skillInstallBanner.title": "Add skills to Lobe AI",
"store.actions.cancel": "Cancel",
"store.actions.configure": "Configure",
"store.actions.confirmUninstall": "Uninstalling will clear Skill config. Continue?",
-19
View File
@@ -291,26 +291,9 @@
"heterogeneousStatus.auth.api": "API",
"heterogeneousStatus.auth.label": "Auth Method",
"heterogeneousStatus.auth.subscription": "Subscription",
"heterogeneousStatus.cloud.githubDesc": "Select a GitHub credential to allow the sandbox to clone your private repositories.",
"heterogeneousStatus.cloud.githubLabel": "GitHub Connection",
"heterogeneousStatus.cloud.githubNoCreds": "No GitHub credentials found.",
"heterogeneousStatus.cloud.githubPlaceholder": "Select a GitHub credential...",
"heterogeneousStatus.cloud.manageCredentials": "Manage Credentials →",
"heterogeneousStatus.cloud.repoAdd": "Add",
"heterogeneousStatus.cloud.repoDesc": "Add repositories to the list. Switch the active one from the bottom bar in the chat view.",
"heterogeneousStatus.cloud.repoLabel": "Repositories",
"heterogeneousStatus.cloud.repoPlaceholder": "owner/repo or https://github.com/owner/repo",
"heterogeneousStatus.cloud.tabLabel": "Cloud",
"heterogeneousStatus.cloud.tokenCancel": "Cancel",
"heterogeneousStatus.cloud.tokenChange": "Change",
"heterogeneousStatus.cloud.tokenDesc": "Your Claude Code OAuth token. Saved securely to Credentials once submitted. Run `claude setup-token` in your terminal to generate one.",
"heterogeneousStatus.cloud.tokenLabel": "Claude Code Token",
"heterogeneousStatus.cloud.tokenPlaceholder": "Paste your OAuth token here",
"heterogeneousStatus.cloud.tokenSave": "Save",
"heterogeneousStatus.command.edit": "Edit command",
"heterogeneousStatus.command.label": "Launch Command",
"heterogeneousStatus.command.placeholder": "Command name or absolute path",
"heterogeneousStatus.desktop.tabLabel": "Desktop",
"heterogeneousStatus.detecting": "Detecting {{name}} CLI...",
"heterogeneousStatus.plan.label": "Plan",
"heterogeneousStatus.redetect": "Re-detect",
@@ -1062,8 +1045,6 @@
"tools.lobehubSkill.providers.linear.readme": "Bring the power of Linear directly into your AI assistant. Create and update issues, manage sprints, track project progress, and streamline your development workflow—all through natural conversation.",
"tools.lobehubSkill.providers.microsoft.description": "Outlook Calendar is an integrated scheduling tool within Microsoft Outlook that enables users to create appointments, organize meetings with others, and manage their time and events effectively.",
"tools.lobehubSkill.providers.microsoft.readme": "Integrate with Outlook Calendar to view, create, and manage your events seamlessly. Schedule meetings, check availability, set reminders, and coordinate your time—all through natural language commands.",
"tools.lobehubSkill.providers.notion.description": "Notion is a collaborative productivity and note-taking application.",
"tools.lobehubSkill.providers.notion.readme": "Connect to Notion to access and manage your workspace. Create pages, search content, update databases, and organize your knowledge base—all through natural conversation with your AI assistant.",
"tools.lobehubSkill.providers.twitter.description": "X (Twitter) is a social media platform for sharing real-time updates, news, and engaging with your audience through posts, replies, and direct messages.",
"tools.lobehubSkill.providers.twitter.readme": "Connect to X (Twitter) to post tweets, manage your timeline, and engage with your audience. Create content, schedule posts, monitor mentions, and build your social media presence through conversational AI.",
"tools.lobehubSkill.providers.vercel.description": "Vercel is a cloud platform for frontend developers, providing hosting and serverless functions to deploy web applications with ease.",
-4
View File
@@ -184,10 +184,6 @@
"groupWizard.searchTemplates": "搜索模板…",
"groupWizard.title": "创建群组",
"groupWizard.useTemplate": "使用模板",
"heteroAgent.cloudRepo.multiSelected": "已选 {{count}} 个仓库",
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
"heteroAgent.cloudRepo.notSet": "未选择仓库",
"heteroAgent.cloudRepo.sectionTitle": "代码仓库",
"heteroAgent.fullAccess.label": "完全访问权限",
"heteroAgent.fullAccess.tooltip": "Claude Code 在本地运行,对工作目录拥有完全的读写权限。当前暂不支持切换权限模式。",
"heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。",
+1 -1
View File
@@ -29,7 +29,7 @@
"batchDelete": "批量删除",
"blog": "产品博客",
"botIntegrationBanner.dismiss": "关闭",
"botIntegrationBanner.title": "在你喜爱的聊天应用中,与 Lobe AI 畅聊。",
"botIntegrationBanner.title": " LobeAI 添加渠道",
"branching": "创建子话题",
"branchingDisable": "「子话题」功能在当前模式下不可用。如需该功能,请切换到 Postgres/Pglite DB 模式或使用 LobeHub Cloud",
"branchingRequiresSavedTopic": "当前话题未保存。保存后即可使用子话题",
-12
View File
@@ -40,18 +40,6 @@
"modifier.acceptAll": "全部保留",
"modifier.reject": "撤销",
"modifier.rejectAll": "全部撤销",
"skillFrontmatter.edit": "编辑元信息",
"skillFrontmatter.empty": "暂无元信息",
"skillFrontmatter.invalid.descriptionInvalid": "描述必须是单行文本。",
"skillFrontmatter.invalid.descriptionRequired": "描述不能为空。",
"skillFrontmatter.invalid.mapping": "YAML 元信息必须是映射。",
"skillFrontmatter.invalid.nameInvalid": "名称只能使用小写字母、数字和连字符。",
"skillFrontmatter.invalid.nameLocked": "名称必须保持为 {{name}}。请通过技能重命名入口修改。",
"skillFrontmatter.invalid.nameRequired": "名称不能为空。",
"skillFrontmatter.invalid.required": "YAML 元信息不能为空。",
"skillFrontmatter.invalid.syntax": "YAML 语法无效。",
"skillFrontmatter.saveFailed": "元信息未保存。请重试,或继续编辑。",
"skillFrontmatter.title": "技能元信息",
"slash.compact": "压缩上下文",
"slash.h1": "一级标题",
"slash.h2": "二级标题",
+1 -1
View File
@@ -681,7 +681,7 @@
"skillDetail.tools": "工具",
"skillDetail.trustWarning": "请仅使用您信任的开发者提供的连接器。LobeHub 无法控制开发者提供哪些工具,也无法验证这些工具是否按预期工作或是否会发生变化。",
"skillInstallBanner.dismiss": "关闭",
"skillInstallBanner.title": "将你的常用软件连接到 Lobe AI.",
"skillInstallBanner.title": " Lobe AI 添加技能",
"store.actions.cancel": "取消安装",
"store.actions.configure": "配置",
"store.actions.confirmUninstall": "即将卸载该技能,卸载后将清除该技能配置,请确认你的操作",
-19
View File
@@ -291,26 +291,9 @@
"heterogeneousStatus.auth.api": "API",
"heterogeneousStatus.auth.label": "认证方式",
"heterogeneousStatus.auth.subscription": "订阅",
"heterogeneousStatus.cloud.githubDesc": "选择 GitHub 凭证,让沙箱能够克隆你的私有仓库。",
"heterogeneousStatus.cloud.githubLabel": "GitHub 连接",
"heterogeneousStatus.cloud.githubNoCreds": "未找到 GitHub 凭证。",
"heterogeneousStatus.cloud.githubPlaceholder": "选择 GitHub 凭证…",
"heterogeneousStatus.cloud.manageCredentials": "管理凭证 →",
"heterogeneousStatus.cloud.repoAdd": "添加",
"heterogeneousStatus.cloud.repoDesc": "在此管理仓库列表,在对话视图底栏切换当前激活的仓库。",
"heterogeneousStatus.cloud.repoLabel": "代码仓库",
"heterogeneousStatus.cloud.repoPlaceholder": "owner/repo 或 https://github.com/owner/repo",
"heterogeneousStatus.cloud.tabLabel": "云端",
"heterogeneousStatus.cloud.tokenCancel": "取消",
"heterogeneousStatus.cloud.tokenChange": "修改",
"heterogeneousStatus.cloud.tokenDesc": "你的 Claude Code OAuth Token,提交后将安全保存到凭证管理。在终端运行 `claude setup-token` 即可获取。",
"heterogeneousStatus.cloud.tokenLabel": "Claude Code Token",
"heterogeneousStatus.cloud.tokenPlaceholder": "粘贴你的 OAuth Token",
"heterogeneousStatus.cloud.tokenSave": "保存",
"heterogeneousStatus.command.edit": "编辑指令",
"heterogeneousStatus.command.label": "启动指令",
"heterogeneousStatus.command.placeholder": "指令名称或绝对路径",
"heterogeneousStatus.desktop.tabLabel": "桌面端",
"heterogeneousStatus.detecting": "正在检测 {{name}} CLI…",
"heterogeneousStatus.plan.label": "方案",
"heterogeneousStatus.redetect": "重新检测",
@@ -1062,8 +1045,6 @@
"tools.lobehubSkill.providers.linear.readme": "将 Linear 的强大功能引入您的 AI 助手。创建和更新问题、管理冲刺、跟踪项目进度,并通过自然对话优化开发流程。",
"tools.lobehubSkill.providers.microsoft.description": "Outlook 日历是 Microsoft Outlook 中集成的日程安排工具,用户可创建约会、组织会议并高效管理时间和事件。",
"tools.lobehubSkill.providers.microsoft.readme": "集成 Outlook 日历以无缝查看、创建和管理事件。安排会议、查看可用时间、设置提醒,并通过自然语言指令协调时间。",
"tools.lobehubSkill.providers.notion.description": "Notion 是一款协作型效率与笔记应用。",
"tools.lobehubSkill.providers.notion.readme": "连接 Notion 以访问和管理您的工作区。创建页面、搜索内容、更新数据库,并通过与 AI 助手的自然对话组织知识库。",
"tools.lobehubSkill.providers.twitter.description": "X(原 Twitter)是一个社交媒体平台,用于分享实时动态、新闻,并通过推文、回复和私信与受众互动。",
"tools.lobehubSkill.providers.twitter.readme": "连接 X(原 Twitter)以发布推文、管理时间线并与受众互动。创建内容、安排发布、监控提及,并通过对话式 AI 构建社交媒体影响力。",
"tools.lobehubSkill.providers.vercel.description": "Vercel 是一个面向前端开发者的云平台,提供托管服务和无服务器函数,可轻松部署 Web 应用。",
@@ -469,7 +469,7 @@ export class GeneralChatAgent implements Agent {
case 'llm_result': {
// LLM response received, check if it contains tool calls
const { hasToolsCalling, toolsCalling, parentMessageId, result } =
const { hasToolsCalling, toolsCalling, parentMessageId } =
context.payload as GeneralAgentCallLLMResultPayload;
if (hasToolsCalling && toolsCalling && toolsCalling.length > 0) {
@@ -516,26 +516,12 @@ export class GeneralChatAgent implements Agent {
return instructions;
}
// Silent-drop diagnostic: LLM emitted raw tool_calls but every one
// failed to resolve to a known tool (e.g. malformed names without the
// `____` separator). Surface this in reasonDetail so dashboards can
// distinguish it from a genuine no-tool completion. See LOBE-8696.
const rawToolCallCount = result?.tool_calls?.length ?? 0;
const hasUnresolvedToolCalls = rawToolCallCount > 0;
// No tool calls, conversation is complete
return {
reason: state.forceFinish ? 'max_steps_completed' : 'completed',
reasonDetail: hasUnresolvedToolCalls
? `LLM returned ${rawToolCallCount} unresolvable tool_calls: ${(
result?.tool_calls ?? []
)
.map((tc) => tc.function?.name)
.filter(Boolean)
.join(', ')}`
: state.forceFinish
? 'Force finish: LLM produced final text response after max steps'
: 'LLM response completed without tool calls',
reasonDetail: state.forceFinish
? 'Force finish: LLM produced final text response after max steps'
: 'LLM response completed without tool calls',
type: 'finish',
};
}
@@ -176,41 +176,6 @@ describe('GeneralChatAgent', () => {
});
});
// Regression for LOBE-8696: when the LLM emits tool_calls whose names
// can't be resolved (e.g. `activateTools` instead of
// `lobe-activator____activateTools`), the agent used to silently finish
// with "completed without tool calls". Surface the unresolved names so
// dashboards can spot the regression.
it('should report unresolvable tool_calls in reasonDetail', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState();
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [],
parentMessageId: 'msg-1',
result: {
content: '',
tool_calls: [
{ id: 't1', type: 'function', function: { name: 'activateTools', arguments: '{}' } },
{ id: 't2', type: 'function', function: { name: 'activateSkill', arguments: '{}' } },
],
},
});
const result = await agent.runner(context, state);
expect(result).toEqual({
type: 'finish',
reason: 'completed',
reasonDetail: 'LLM returned 2 unresolvable tool_calls: activateTools, activateSkill',
});
});
it('should return call_tool for single tool that does not need intervention', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
@@ -58,21 +58,21 @@ export const systemPrompt = `You have access to a Tools Activator that allows yo
- Task requires environment variables (e.g., \`OPENAI_API_KEY\`, \`GITHUB_TOKEN\`)
- User wants to store or manage sensitive information securely
- Sandbox code execution requires credentials/secrets to be injected
- User asks to connect to services like GitHub, Linear, Microsoft, Notion, Twitter, etc.
- User asks to connect to services like GitHub, Linear, Twitter, Microsoft, etc.
- User wants to use, open, connect, or interact with a third-party integration service
(e.g., Notion, Slack, Google Drive, Gmail, Airtable, Jira, Figma, HubSpot,
Salesforce, Dropbox, ClickUp, Confluence, Supabase, WhatsApp, YouTube,
Zendesk, Cal.com, OneDrive, Outlook Mail, Google Sheets, Google Docs)
- User says things like "help me use Notion", "connect my Slack", "open Google Drive",
"I want to use Jira", "set up Airtable" — these are third-party OAuth services
"I want to use Jira", "set up Airtable" — these are Klavis-managed OAuth services
**Decision flow:**
1. **If ANY trigger condition above is met** → Immediately activate \`lobe-creds\`
2. Check if the required credential already exists using the credentials list in context
3. If credential exists → use \`getPlaintextCred\` or \`injectCredsToSandbox\` (for sandbox execution)
4. If credential doesn't exist:
- For LobeHub OAuth services (GitHub, Linear, Microsoft, Notion, Twitter) → use \`initiateOAuthConnect\`
- For Klavis-managed services (Slack, Google Drive, Airtable, Jira, etc.)
- For OAuth services (GitHub, Linear, Microsoft, Twitter) → use \`initiateOAuthConnect\`
- For Klavis-managed services (Notion, Slack, Google Drive, Airtable, Jira, etc.)
→ use \`connectKlavisService\` after activating \`lobe-creds\`. The full list of
available Klavis services is shown in \`<klavis_integrations>\` inside the
lobe-creds system prompt.
@@ -8,7 +8,6 @@ import type {
InjectCredsToSandboxParams,
SaveCredsParams,
} from '../types';
import { LOBEHUB_OAUTH_PROVIDER_LIST } from '../types';
/**
* Service interface for Credentials operations
@@ -155,7 +154,7 @@ export class CredsExecutionRuntime {
const providerConfig = getLobehubSkillProviderById(provider);
if (!providerConfig) {
return {
content: `Unknown OAuth provider: ${provider}. Available providers: ${LOBEHUB_OAUTH_PROVIDER_LIST}`,
content: `Unknown OAuth provider: ${provider}. Available providers: github, linear, microsoft, twitter, vercel`,
error: {
message: `Unknown OAuth provider: ${provider}`,
type: 'UnknownProvider',
@@ -11,14 +11,14 @@ import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
import { CredsIdentifier } from '../manifest';
import type {
ConnectKlavisServiceParams,
GetPlaintextCredParams,
InitiateOAuthConnectParams,
InjectCredsToSandboxParams,
SaveCredsParams,
import {
type ConnectKlavisServiceParams,
CredsApiName,
type GetPlaintextCredParams,
type InitiateOAuthConnectParams,
type InjectCredsToSandboxParams,
type SaveCredsParams,
} from '../types';
import { CredsApiName, LOBEHUB_OAUTH_PROVIDER_LIST } from '../types';
const log = debug('lobe-creds:executor');
@@ -170,7 +170,7 @@ class CredsExecutor extends BaseExecutor<typeof CredsApiName> {
if (!providerConfig) {
return {
error: {
message: `Unknown OAuth provider: ${provider}. Available providers: ${LOBEHUB_OAUTH_PROVIDER_LIST}`,
message: `Unknown OAuth provider: ${provider}. Available providers: github, linear, microsoft, twitter`,
type: 'UnknownProvider',
},
success: false,
+7 -6
View File
@@ -2,7 +2,7 @@ import type { BuiltinToolManifest } from '@lobechat/types';
import type { JSONSchema7 } from 'json-schema';
import { systemPrompt } from './systemRole';
import { CredsApiName, LOBEHUB_OAUTH_PROVIDER_IDS, LOBEHUB_OAUTH_PROVIDER_LIST } from './types';
import { CredsApiName } from './types';
export const CredsIdentifier = 'lobe-creds';
@@ -10,14 +10,14 @@ export const CredsManifest: BuiltinToolManifest = {
api: [
{
description:
'Connect a Klavis integration service via OAuth. Use this to authorize access to third-party services managed by the Klavis platform (e.g., Gmail, Google Calendar, Slack). Check the available Klavis services in the credentials context before calling this.',
'Connect a Klavis integration service via OAuth. Use this to authorize access to third-party services managed by the Klavis platform (e.g., Notion, Gmail, Google Calendar, Slack). Check the available Klavis services in the credentials context before calling this.',
name: CredsApiName.connectKlavisService,
parameters: {
additionalProperties: false,
properties: {
service: {
description:
'The Klavis service identifier to connect (e.g., "gmail", "google-calendar"). See the available Klavis services list in the credentials context.',
'The Klavis service identifier to connect (e.g., "notion", "gmail", "google-calendar"). See the available Klavis services list in the credentials context.',
type: 'string',
},
},
@@ -27,14 +27,15 @@ export const CredsManifest: BuiltinToolManifest = {
},
{
description:
'Initiate OAuth connection flow for a LobeHub Skill provider (e.g., GitHub, Linear, Microsoft Outlook, Notion, Twitter/X). Returns an authorization URL that the user must click to authorize. After authorization, the credential will be automatically saved.',
'Initiate OAuth connection flow for a third-party service (e.g., Linear, Microsoft Outlook, Twitter/X). Returns an authorization URL that the user must click to authorize. After authorization, the credential will be automatically saved.',
name: CredsApiName.initiateOAuthConnect,
parameters: {
additionalProperties: false,
properties: {
provider: {
description: `The OAuth provider ID. Available providers: ${LOBEHUB_OAUTH_PROVIDER_LIST}`,
enum: [...LOBEHUB_OAUTH_PROVIDER_IDS],
description:
'The OAuth provider ID. Available providers: "linear" (issue tracking), "microsoft" (Outlook Calendar), "twitter" (X/Twitter)',
enum: ['linear', 'microsoft', 'twitter', 'github'],
type: 'string',
},
},
@@ -40,7 +40,6 @@ LobeHub provides built-in OAuth integrations for the following services:
- **github**: GitHub repository and code management. Connect to access repositories, create issues, manage pull requests.
- **linear**: Linear issue tracking and project management. Connect to create/manage issues, track projects.
- **microsoft**: Microsoft Outlook Calendar. Connect to view/create calendar events, manage meetings.
- **notion**: Notion workspace and knowledge management. Connect to create pages, search content, update databases, and organize workspace knowledge.
- **twitter**: X (Twitter) social media. Connect to post tweets, manage timeline, engage with audience.
When a user mentions they want to use one of these services, use \`initiateOAuthConnect\` to provide them with an authorization link. After they authorize, the credential will be automatically saved and available for use.
@@ -137,7 +136,7 @@ runCommand({
</klavis_integrations>
<klavis_guidelines>
- **Klavis integrations** are OAuth connections managed by the Klavis platform for third-party services (e.g., Gmail, Google Calendar, Slack).
- **Klavis integrations** are OAuth connections managed by the Klavis platform for third-party services (e.g., Notion, Gmail, Google Calendar, Slack).
- For **connected** Klavis services: Use the corresponding tools directly. Do NOT ask users for API keys, tokens, or credentials — the authorization is already handled by Klavis.
- For **available but not connected** services: Use \`connectKlavisService\` to initiate the OAuth connection flow via Klavis.
- Klavis credentials **CANNOT** be retrieved via \`getPlaintextCred\` or injected via \`injectCredsToSandbox\` — they are tool-only authorizations managed externally by Klavis.
+4 -16
View File
@@ -3,7 +3,7 @@ import type { CredType } from '@lobechat/types';
export const CredsApiName = {
/**
* Connect a Klavis integration service via OAuth
* Initiates Klavis OAuth flow for third-party services like Gmail, Google Calendar, etc.
* Initiates Klavis OAuth flow for third-party services like Notion, Gmail, etc.
*/
connectKlavisService: 'connectKlavisService',
@@ -34,18 +34,6 @@ export const CredsApiName = {
export type CredsApiNameType = (typeof CredsApiName)[keyof typeof CredsApiName];
export const LOBEHUB_OAUTH_PROVIDER_IDS = [
'github',
'linear',
'microsoft',
'notion',
'twitter',
] as const;
export const LOBEHUB_OAUTH_PROVIDER_LIST = LOBEHUB_OAUTH_PROVIDER_IDS.join(', ');
export type LobehubOAuthProviderId = (typeof LOBEHUB_OAUTH_PROVIDER_IDS)[number];
// ==================== Tool Parameter Types ====================
export interface GetPlaintextCredParams {
@@ -61,9 +49,9 @@ export interface GetPlaintextCredParams {
export interface InitiateOAuthConnectParams {
/**
* The OAuth provider ID (e.g., 'linear', 'microsoft', 'notion', 'twitter')
* The OAuth provider ID (e.g., 'linear', 'microsoft', 'twitter')
*/
provider: LobehubOAuthProviderId;
provider: string;
}
export interface InitiateOAuthConnectState {
@@ -160,7 +148,7 @@ export interface SaveCredsState {
export interface ConnectKlavisServiceParams {
/**
* The Klavis service identifier to connect (e.g., 'gmail', 'google-calendar')
* The Klavis service identifier to connect (e.g., 'notion', 'gmail', 'google-calendar')
*/
service: string;
}
@@ -1,119 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditTaskParams } from '../../../types';
import { EditTaskInspector } from './index';
interface AgentDisplayMeta {
avatar?: string;
backgroundColor?: string;
title?: string;
}
interface AgentDisplayMetaOptions {
fallbackToDefault?: boolean;
}
const mocks = vi.hoisted(() => ({
agentMetaById: {} as Record<string, AgentDisplayMeta | undefined>,
}));
vi.mock('@/features/AgentTasks/features/AssigneeAvatar', () => ({
default: ({
agentId,
fallbackToDefault,
}: {
agentId?: string | null;
fallbackToDefault?: boolean;
}) => (
<span
data-agent-id={agentId || ''}
data-fallback-to-default={String(fallbackToDefault)}
data-testid="assignee-avatar"
/>
),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key.split('.').at(-1) || key,
}),
}));
vi.mock('@/features/AgentTasks/shared/useAgentDisplayMeta', () => ({
useAgentDisplayMeta: (id: string, options?: AgentDisplayMetaOptions) =>
mocks.agentMetaById[id] ||
(options?.fallbackToDefault === false
? undefined
: {
avatar: 'default-avatar',
backgroundColor: '#ffffff',
title: 'Default Agent',
}),
}));
vi.mock('@/styles', () => ({
inspectorTextStyles: { root: 'inspector-root' },
shinyTextStyles: { shinyText: 'shiny-text' },
}));
const renderInspector = (args: Partial<EditTaskParams>) =>
render(
<EditTaskInspector
apiName="editTask"
args={{ identifier: 'T-1', ...args }}
identifier="lobe-task"
/>,
);
describe('EditTaskInspector', () => {
beforeEach(() => {
mocks.agentMetaById = {};
});
afterEach(() => {
cleanup();
});
it('renders assignee metadata as an avatar chip', () => {
mocks.agentMetaById.agt_worker = {
avatar: 'worker-avatar',
backgroundColor: '#123456',
title: 'Worker Agent',
};
renderInspector({ assigneeAgentId: 'agt_worker' });
expect(screen.getByTestId('assignee-avatar').dataset.agentId).toBe('agt_worker');
expect(screen.getByTestId('assignee-avatar').dataset.fallbackToDefault).toBe('false');
expect(screen.getByText('Worker Agent')).toBeTruthy();
expect(screen.queryByText('agt_worker')).toBeNull();
});
it('falls back to the agent id when assignee metadata is unavailable', () => {
renderInspector({ assigneeAgentId: 'agt_missing' });
expect(screen.getByTestId('assignee-avatar').dataset.agentId).toBe('agt_missing');
expect(screen.getByTestId('assignee-avatar').dataset.fallbackToDefault).toBe('false');
expect(screen.getByText('agt_missing')).toBeTruthy();
expect(screen.queryByText('Default Agent')).toBeNull();
});
it('renders the resolved agent name instead of the raw assignee id', () => {
mocks.agentMetaById.agt_lobe = {
avatar: 'lobe-avatar',
backgroundColor: '#123456',
title: 'Lobe AI',
};
renderInspector({ assigneeAgentId: 'agt_lobe' });
expect(screen.getByTestId('assignee-avatar').dataset.agentId).toBe('agt_lobe');
expect(screen.getByTestId('assignee-avatar').dataset.fallbackToDefault).toBe('false');
expect(screen.getByText('Lobe AI')).toBeTruthy();
expect(screen.queryByText('agt_lobe')).toBeNull();
});
});
@@ -3,12 +3,9 @@
import { priorityLabel } from '@lobechat/prompts';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import type { ReactNode } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import AssigneeAvatar from '@/features/AgentTasks/features/AssigneeAvatar';
import { useAgentDisplayMeta } from '@/features/AgentTasks/shared/useAgentDisplayMeta';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { EditTaskParams, EditTaskState } from '../../../types';
@@ -27,32 +24,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
background: ${cssVar.colorSuccessBg};
`,
assigneeAvatar: css`
flex-shrink: 0;
`,
assigneeChip: css`
overflow: hidden;
display: inline-flex;
flex-shrink: 1;
gap: 6px;
align-items: center;
min-width: 0;
max-width: 220px;
padding-block: 1px;
padding-inline: 4px 8px;
border-radius: 999px;
font-size: 12px;
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
`,
assigneeName: css`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`,
chip: css`
overflow: hidden;
display: inline-flex;
@@ -113,22 +84,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
`,
}));
const AssigneeChip = memo<{ agentId: string }>(({ agentId }) => {
const agentMeta = useAgentDisplayMeta(agentId, { fallbackToDefault: false });
const displayName = agentMeta?.title || agentId;
return (
<span className={styles.assigneeChip} title={displayName}>
<span className={styles.assigneeAvatar}>
<AssigneeAvatar agentId={agentId} fallbackToDefault={false} size={16} />
</span>
<span className={styles.assigneeName}>{displayName}</span>
</span>
);
});
AssigneeChip.displayName = 'AssigneeChip';
export const EditTaskInspector = memo<BuiltinInspectorProps<EditTaskParams, EditTaskState>>(
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
@@ -136,7 +91,7 @@ export const EditTaskInspector = memo<BuiltinInspectorProps<EditTaskParams, Edit
const params = args || partialArgs || ({} as Partial<EditTaskParams>);
const identifier = params.identifier;
const segments: { content: ReactNode; key: string }[] = [];
const segments: { content: React.ReactNode; key: string }[] = [];
if (params.name !== undefined) {
segments.push({
@@ -199,7 +154,7 @@ export const EditTaskInspector = memo<BuiltinInspectorProps<EditTaskParams, Edit
) : (
<>
<span className={styles.label}>{t('builtins.lobe-task.edit.assign')}</span>
<AssigneeChip agentId={params.assigneeAgentId} />
<span className={styles.chip}>{params.assigneeAgentId}</span>
</>
),
key: 'assignee',
@@ -1,18 +0,0 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const packageDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(packageDir, '../..');
export default defineConfig({
resolve: {
alias: {
'@': resolve(repoRoot, 'src'),
},
},
test: {
environment: 'happy-dom',
},
});
+11
View File
@@ -49,6 +49,17 @@ export const KLAVIS_SERVER_TYPES: KlavisServerType[] = [
label: 'Google Calendar',
serverName: Klavis.McpServerName.GoogleCalendar,
},
{
author: 'Klavis',
authorUrl: 'https://klavis.io',
description: 'Notion is a collaborative productivity and note-taking application',
icon: 'https://hub-apac-1.lobeobjects.space/assets/logos/notion.svg',
identifier: 'notion',
readme:
'Connect to Notion to access and manage your workspace. Create pages, search content, update databases, and organize your knowledge base—all through natural conversation with your AI assistant.',
label: 'Notion',
serverName: Klavis.McpServerName.Notion,
},
{
author: 'Klavis',
authorUrl: 'https://klavis.io',
-11
View File
@@ -81,17 +81,6 @@ export const LOBEHUB_SKILL_PROVIDERS: LobehubSkillProviderType[] = [
'Integrate with Outlook Calendar to view, create, and manage your events seamlessly. Schedule meetings, check availability, set reminders, and coordinate your time—all through natural language commands.',
label: 'Outlook Calendar',
},
{
author: 'LobeHub',
authorUrl: 'https://lobehub.com',
defaultVisible: true,
description: 'Notion is a collaborative productivity and note-taking application.',
icon: 'https://hub-apac-1.lobeobjects.space/assets/logos/notion.svg',
id: 'notion',
readme:
'Connect to Notion to access and manage your workspace. Create pages, search content, update databases, and organize your knowledge base—all through natural conversation with your AI assistant.',
label: 'Notion',
},
{
author: 'LobeHub',
authorUrl: 'https://lobehub.com',
+1 -1
View File
@@ -18,10 +18,10 @@ export const RECOMMENDED_SKILLS: RecommendedSkillItem[] = [
{ id: 'lobe-agent-documents', type: RecommendedSkillType.Builtin },
{ id: 'lobe-message', type: RecommendedSkillType.Builtin },
// LobeHub skills
{ id: 'notion', type: RecommendedSkillType.Lobehub },
{ id: 'twitter', type: RecommendedSkillType.Lobehub },
// Klavis skills
{ id: 'gmail', type: RecommendedSkillType.Klavis },
{ id: 'notion', type: RecommendedSkillType.Klavis },
{ id: 'google-drive', type: RecommendedSkillType.Klavis },
{ id: 'google-calendar', type: RecommendedSkillType.Klavis },
{ id: 'slack', type: RecommendedSkillType.Klavis },
+5 -5
View File
@@ -121,7 +121,7 @@ export const taskTemplates: TaskTemplate[] = [
category: 'content-creation',
cronPattern: '0 20 * * 0',
interests: ['writing', 'creator'],
optionalSkills: [{ provider: 'notion', source: 'lobehub' }],
optionalSkills: [{ provider: 'notion', source: 'klavis' }],
},
// engineering
@@ -363,7 +363,7 @@ export const taskTemplates: TaskTemplate[] = [
category: 'product',
cronPattern: '0 15 * * 5',
interests: ['product'],
requiresSkills: [{ provider: 'notion', source: 'lobehub' }],
requiresSkills: [{ provider: 'notion', source: 'klavis' }],
},
// sales-customer
@@ -445,7 +445,7 @@ export const taskTemplates: TaskTemplate[] = [
category: 'hr',
cronPattern: '0 9 * * 1',
interests: ['hr'],
requiresSkills: [{ provider: 'notion', source: 'lobehub' }],
requiresSkills: [{ provider: 'notion', source: 'klavis' }],
},
{
id: 'team-status-weekly',
@@ -480,7 +480,7 @@ export const taskTemplates: TaskTemplate[] = [
category: 'finance-legal',
cronPattern: '0 9 * * 1',
interests: ['finance-legal'],
requiresSkills: [{ provider: 'notion', source: 'lobehub' }],
requiresSkills: [{ provider: 'notion', source: 'klavis' }],
},
{
id: 'regulation-watch-weekly',
@@ -645,7 +645,7 @@ export const taskTemplates: TaskTemplate[] = [
category: 'personal-life',
cronPattern: '0 22 * * *',
interests: ['personal'],
optionalSkills: [{ provider: 'notion', source: 'lobehub' }],
optionalSkills: [{ provider: 'notion', source: 'klavis' }],
},
];
@@ -79,57 +79,19 @@ export class ToolNameResolver {
* Resolve tool calls from AI response back to original tool information
* @param toolCalls - Tool calls from AI model response
* @param manifests - Available tool manifests mapped by identifier
* @param offeredToolNames - Tool names actually sent to the LLM in this turn
* (e.g. `lobe-activator____activateTools`). When provided, the
* missing-prefix fallback only considers tools in this list, so a model
* can't trigger tools that weren't enabled for the current call and
* disabled duplicates can't shadow enabled ones.
* @returns Resolved tool payloads
*/
resolve(
toolCalls: MessageToolCall[],
manifests: Record<string, LobeToolManifest>,
offeredToolNames?: string[],
): ChatToolPayload[] {
const offeredSet = offeredToolNames ? new Set(offeredToolNames) : null;
return toolCalls
.map((toolCall): ChatToolPayload | null => {
const [initialIdentifier, initialApiName, type] =
const [initialIdentifier, apiName, type] =
toolCall.function.name.split(PLUGIN_SCHEMA_SEPARATOR);
let identifier = initialIdentifier;
let apiName = initialApiName;
// Fallback for malformed tool names without the `____` separator
// (e.g. model returns "activateTools" instead of
// "lobe-activator____activateTools"). When the bare name uniquely
// matches an API across the manifests we're allowed to consider,
// recover the identifier so we don't silently drop the tool call.
// The manifest's `type` is picked up by the existing `type ??
// manifests[identifier]?.type` fallback when building the payload.
if (!apiName) {
const bareName = initialIdentifier;
const matches: string[] = [];
for (const [id, manifest] of Object.entries(manifests)) {
const matchedApi = manifest?.api?.find(
(api: LobeChatPluginApi) => api.name === bareName,
);
if (!matchedApi) continue;
// Restrict to tools actually offered to the LLM this turn so a
// model can't reach tools that weren't enabled, and so disabled
// duplicates don't make an enabled call look ambiguous.
if (offeredSet && !offeredSet.has(this.generate(id, matchedApi.name, manifest.type))) {
continue;
}
matches.push(id);
}
if (matches.length === 1) {
identifier = matches[0];
apiName = bareName;
} else {
return null;
}
}
if (!apiName) return null;
// Step 1: Resolve hashed identifier if needed
if (identifier.startsWith(PLUGIN_SCHEMA_API_MD5_PREFIX)) {
@@ -714,255 +714,6 @@ describe('ToolNameResolver', () => {
expect(result).toEqual([]);
});
// Regression for LOBE-8696: some models (e.g. deepseek-v4-pro) drop the
// `<identifier>____` prefix and emit only the bare API name. When that
// bare name uniquely matches an API in the available manifests, we should
// recover the identifier from the manifest instead of silently dropping
// the call (which previously caused empty assistant bubbles).
describe('resolve - missing-prefix fallback', () => {
it('should recover identifier when bare API name uniquely matches a manifest', () => {
const toolCalls = [
{
function: { arguments: '{"toolIds": ["foo"]}', name: 'activateTools' },
id: 'call_1',
type: 'function',
},
];
const manifests = {
'lobe-activator': {
api: [{ description: 'Activate tools', name: 'activateTools', parameters: {} }],
identifier: 'lobe-activator',
meta: {},
type: 'builtin' as const,
},
'lobe-skills': {
api: [{ description: 'Activate skill', name: 'activateSkill', parameters: {} }],
identifier: 'lobe-skills',
meta: {},
type: 'builtin' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
apiName: 'activateTools',
arguments: '{"toolIds": ["foo"]}',
id: 'call_1',
identifier: 'lobe-activator',
type: 'builtin',
});
});
it('should drop bare API names when no manifest exposes them', () => {
const toolCalls = [
{
function: { arguments: '{}', name: 'unknownAction' },
id: 'call_1',
type: 'function',
},
];
const manifests = {
'lobe-activator': {
api: [{ description: '', name: 'activateTools', parameters: {} }],
identifier: 'lobe-activator',
meta: {},
type: 'builtin' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toEqual([]);
});
it('should drop bare API names when multiple manifests expose the same name (ambiguous)', () => {
const toolCalls = [
{
function: { arguments: '{}', name: 'createDocument' },
id: 'call_1',
type: 'function',
},
];
const manifests = {
'lobe-agent-documents': {
api: [{ description: '', name: 'createDocument', parameters: {} }],
identifier: 'lobe-agent-documents',
meta: {},
type: 'builtin' as const,
},
'lobe-notebook': {
api: [{ description: '', name: 'createDocument', parameters: {} }],
identifier: 'lobe-notebook',
meta: {},
type: 'builtin' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toEqual([]);
});
it('should preserve manifest type when recovering identifier', () => {
const toolCalls = [
{
function: { arguments: '{}', name: 'open_page' },
id: 'call_1',
type: 'function',
},
];
const manifests = {
'mcp-browser': {
api: [{ description: '', name: 'open_page', parameters: {} }],
identifier: 'mcp-browser',
meta: {},
type: 'mcp' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(1);
expect(result[0].identifier).toBe('mcp-browser');
expect(result[0].apiName).toBe('open_page');
expect(result[0].type).toBe('mcp');
});
// The fallback's manifests map can be broader than the tools actually
// sent to the LLM (e.g. the client builds it from every installed
// plugin and every builtin). Without a turn-scope restriction, a
// malformed bare name could resolve to a tool that wasn't enabled, or
// a disabled duplicate could shadow an enabled call. The optional
// `offeredToolNames` parameter restricts the fallback to tools that
// were actually offered this turn.
it('should restrict fallback to tools actually offered this turn', () => {
const toolCalls = [
{
function: { arguments: '{}', name: 'activateTools' },
id: 'call_1',
type: 'function',
},
];
const manifests = {
'lobe-activator': {
api: [{ description: '', name: 'activateTools', parameters: {} }],
identifier: 'lobe-activator',
meta: {},
type: 'builtin' as const,
},
// Disabled this turn — must not be reachable via fallback
'lobe-activator-deprecated': {
api: [{ description: '', name: 'activateTools', parameters: {} }],
identifier: 'lobe-activator-deprecated',
meta: {},
type: 'builtin' as const,
},
};
const result = resolver.resolve(toolCalls, manifests, ['lobe-activator____activateTools']);
expect(result).toHaveLength(1);
expect(result[0].identifier).toBe('lobe-activator');
expect(result[0].apiName).toBe('activateTools');
});
it('should drop bare API names whose tool was not offered this turn', () => {
const toolCalls = [
{
function: { arguments: '{}', name: 'activateTools' },
id: 'call_1',
type: 'function',
},
];
const manifests = {
'lobe-activator': {
api: [{ description: '', name: 'activateTools', parameters: {} }],
identifier: 'lobe-activator',
meta: {},
type: 'builtin' as const,
},
};
// Manifest exists but the tool was not sent to the LLM this turn.
const result = resolver.resolve(toolCalls, manifests, ['lobe-skills____activateSkill']);
expect(result).toEqual([]);
});
it('should treat an enabled call as unique when a disabled duplicate would have made it ambiguous', () => {
const toolCalls = [
{
function: { arguments: '{}', name: 'createDocument' },
id: 'call_1',
type: 'function',
},
];
const manifests = {
// Only this manifest's createDocument is offered this turn.
'lobe-agent-documents': {
api: [{ description: '', name: 'createDocument', parameters: {} }],
identifier: 'lobe-agent-documents',
meta: {},
type: 'builtin' as const,
},
// Installed but not offered — without the offered-list restriction
// this would make the fallback ambiguous and drop the valid call.
'lobe-notebook': {
api: [{ description: '', name: 'createDocument', parameters: {} }],
identifier: 'lobe-notebook',
meta: {},
type: 'builtin' as const,
},
};
const result = resolver.resolve(toolCalls, manifests, [
'lobe-agent-documents____createDocument',
]);
expect(result).toHaveLength(1);
expect(result[0].identifier).toBe('lobe-agent-documents');
});
it('should respect hashed offered names when matching', () => {
const identifier = 'mcp-server';
const apiName = 'get.current/weather';
// generate produces a hashed tool name for invalid characters
const offeredName = resolver.generate(identifier, apiName, 'mcp');
const toolCalls = [
{
function: { arguments: '{}', name: apiName },
id: 'call_1',
type: 'function',
},
];
const manifests = {
[identifier]: {
api: [{ description: '', name: apiName, parameters: {} }],
identifier,
meta: {},
type: 'mcp' as const,
},
};
const result = resolver.resolve(toolCalls, manifests, [offeredName]);
expect(result).toHaveLength(1);
expect(result[0].identifier).toBe(identifier);
expect(result[0].apiName).toBe(apiName);
});
});
it('should handle tool calls with different types', () => {
const toolCalls = [
{
-7
View File
@@ -10,13 +10,6 @@ export interface HeterogeneousProviderConfig {
command?: string;
/** Custom environment variables */
env?: Record<string, string>;
/**
* Static context prepended to every user prompt before it reaches the agent CLI.
* Use this to prime the agent with workspace conventions, rules, or instructions
* that should apply to every conversation.
* Combined with any runtime-generated context (e.g. cloned repo list).
*/
systemContext?: string;
/** Agent runtime type */
type: 'claude-code' | 'codex';
}
@@ -11,14 +11,6 @@ export interface ExecAgentAppContext {
documentId?: string | null;
/** Group ID for group chat */
groupId?: string | null;
/**
* Initial metadata to merge into the topic when a new topic is created for
* this execution. Ignored when a topicId is already provided (existing topic).
*/
initialTopicMetadata?: {
repos?: string[];
workingDirectory?: string;
};
/** Scope identifier */
scope?: string | null;
/** Session ID */
+1 -10
View File
@@ -114,12 +114,6 @@ export interface ChatTopicMetadata {
onboardingFeedback?: OnboardingFeedbackEntry;
onboardingSession?: OnboardingSessionSnapshot;
provider?: string;
/**
* Web (cloud) only. Ordered list of GitHub repos selected for this topic.
* Each repo will be cloned into the Gateway sandbox before execution.
* `workingDirectory` is kept in sync with repos[0] (the primary repo).
*/
repos?: string[];
/**
* Currently running Gateway operation on this topic.
* Set when agent execution starts, cleared when it completes/fails.
@@ -134,13 +128,10 @@ export interface ChatTopicMetadata {
userMemoryExtractRunState?: TopicUserMemoryExtractRunState;
userMemoryExtractStatus?: 'pending' | 'completed' | 'failed';
/**
* Topic-level working directory.
* On desktop: local filesystem path for the CC session cwd.
* On web (cloud): URL of the primary GitHub repo (first item of `repos`).
* Topic-level working directory (desktop only).
* Priority is higher than Agent-level settings. Also serves as the
* binding cwd for a CC session — written on first CC execution and
* checked on subsequent turns to decide whether `--resume` is safe.
* For sidebar grouping, topics are bucketed by this field (byProject mode).
*/
workingDirectory?: string;
}
@@ -24,7 +24,6 @@ import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import AgentSelectorAction from './AgentSelectorAction';
import { useTaskAgentSelection } from './TaskAgentProvider';
import Toolbar from './Toolbar';
const Search = actionMap['search'];
@@ -45,9 +44,11 @@ const Welcome = memo(() => {
Welcome.displayName = 'Welcome';
const Conversation = memo(() => {
const useFetchAgentConfig = useAgentStore((s) => s.useFetchAgentConfig);
const [setActiveAgentId, useFetchAgentConfig] = useAgentStore((s) => [
s.setActiveAgentId,
s.useFetchAgentConfig,
]);
const currentAgentId = useConversationStore(conversationSelectors.agentId);
const selectTaskAgent = useTaskAgentSelection();
useFetchAgentConfig(true, currentAgentId);
@@ -60,9 +61,9 @@ const Conversation = memo(() => {
const handleAgentChange = useCallback(
(id: string) => {
if (!id || id === currentAgentId || isChatGroupSessionId(id)) return;
selectTaskAgent(id);
setActiveAgentId(id);
},
[currentAgentId, selectTaskAgent],
[currentAgentId, setActiveAgentId],
);
const leftContent = useMemo(
@@ -2,11 +2,11 @@
* @vitest-environment happy-dom
*/
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TaskAgentProvider, useTaskAgentSelection } from './TaskAgentProvider';
import { TaskAgentProvider } from './TaskAgentProvider';
const mocks = vi.hoisted(() => {
const agentState = {
@@ -89,11 +89,6 @@ vi.mock('react-router-dom', () => ({
useMatch: () => mocks.routeMatch,
}));
const SelectAgentButton = ({ agentId }: { agentId: string }) => {
const selectTaskAgent = useTaskAgentSelection();
return <button onClick={() => selectTaskAgent(agentId)}>select agent</button>;
};
describe('TaskAgentProvider', () => {
beforeEach(() => {
mocks.agentState.activeAgentId = undefined;
@@ -108,10 +103,6 @@ describe('TaskAgentProvider', () => {
mocks.routeMatch = undefined;
});
afterEach(() => {
cleanup();
});
it('initializes builtin agents and builds task list context', () => {
render(
<TaskAgentProvider>
@@ -142,28 +133,6 @@ describe('TaskAgentProvider', () => {
expect(mocks.providerContexts.at(-1)?.viewedTask).toEqual({ taskId: 'T-1', type: 'detail' });
});
it('defaults to the task agent when the global active agent comes from another page', async () => {
mocks.agentState.activeAgentId = 'agt_lobe';
mocks.chatState.activeAgentId = 'agt_lobe';
mocks.chatState.activeTopicId = 'tpc_lobe';
render(
<TaskAgentProvider>
<div>content</div>
</TaskAgentProvider>,
);
await waitFor(() => {
expect(mocks.providerContexts.at(-1)?.agentId).toBe('agt_task');
});
expect(mocks.agentState.setActiveAgentId).toHaveBeenCalledWith('agt_task');
expect(mocks.chatState.activeAgentId).toBe('agt_task');
expect(mocks.chatState.switchTopic).toHaveBeenCalledWith(null, {
scope: 'task',
skipRefreshMessage: true,
});
});
it('clears a stale topic only once for the selected agent', async () => {
mocks.chatState.activeTopicId = 'tpc_stale';
@@ -195,101 +164,6 @@ describe('TaskAgentProvider', () => {
expect(mocks.chatState.switchTopic).not.toHaveBeenCalled();
});
it('keeps the current task topic when switching between task list and detail', async () => {
const { rerender } = render(
<TaskAgentProvider>
<div>content</div>
</TaskAgentProvider>,
);
await waitFor(() => {
expect(mocks.chatState.activeAgentId).toBe('agt_task');
});
mocks.chatState.switchTopic.mockClear();
mocks.chatState.activeTopicId = 'tpc_created';
mocks.routeMatch = { params: { taskId: 'T-1' } };
rerender(
<TaskAgentProvider>
<div>content</div>
</TaskAgentProvider>,
);
await waitFor(() => {
expect(mocks.providerContexts.at(-1)?.topicId).toBe('tpc_created');
});
expect(mocks.providerContexts.at(-1)?.viewedTask).toEqual({ taskId: 'T-1', type: 'detail' });
expect(mocks.chatState.switchTopic).not.toHaveBeenCalled();
});
it('resets the scoped agent when the task workspace remounts', async () => {
const firstRender = render(
<TaskAgentProvider>
<SelectAgentButton agentId="agt_custom" />
</TaskAgentProvider>,
);
await waitFor(() => {
expect(mocks.chatState.activeAgentId).toBe('agt_task');
});
fireEvent.click(screen.getByText('select agent'));
await waitFor(() => {
expect(mocks.providerContexts.at(-1)?.agentId).toBe('agt_custom');
});
firstRender.unmount();
mocks.providerContexts = [];
mocks.agentState.activeAgentId = 'agt_lobe';
mocks.chatState.activeAgentId = 'agt_lobe';
mocks.chatState.activeTopicId = 'tpc_lobe';
mocks.chatState.switchTopic.mockClear();
render(
<TaskAgentProvider>
<div>content</div>
</TaskAgentProvider>,
);
await waitFor(() => {
expect(mocks.providerContexts.at(-1)?.agentId).toBe('agt_task');
});
expect(mocks.agentState.setActiveAgentId).toHaveBeenLastCalledWith('agt_task');
expect(mocks.chatState.switchTopic).toHaveBeenCalledWith(null, {
scope: 'task',
skipRefreshMessage: true,
});
});
it('allows the task manager selector to switch the scoped agent', async () => {
render(
<TaskAgentProvider>
<SelectAgentButton agentId="agt_custom" />
</TaskAgentProvider>,
);
await waitFor(() => {
expect(mocks.chatState.activeAgentId).toBe('agt_task');
});
mocks.chatState.switchTopic.mockClear();
mocks.chatState.activeTopicId = 'tpc_task';
fireEvent.click(screen.getByText('select agent'));
await waitFor(() => {
expect(mocks.providerContexts.at(-1)?.agentId).toBe('agt_custom');
});
expect(mocks.agentState.setActiveAgentId).toHaveBeenLastCalledWith('agt_custom');
expect(mocks.chatState.activeAgentId).toBe('agt_custom');
expect(mocks.chatState.switchTopic).toHaveBeenCalledWith(null, {
scope: 'task',
skipRefreshMessage: true,
});
});
it('resets transient state on scoped agent sync even without an active topic', async () => {
mocks.agentState.activeAgentId = 'agt_task';
mocks.chatState.activeAgentId = 'agt_previous';
@@ -2,7 +2,7 @@ import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import type { ConversationContext } from '@lobechat/types';
import { isChatGroupSessionId } from '@lobechat/types';
import type { ReactNode } from 'react';
import { createContext, memo, use, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useEffect, useMemo, useRef } from 'react';
import { useMatch } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading';
@@ -18,30 +18,22 @@ interface TaskAgentProviderProps {
children: ReactNode;
}
const TaskAgentSelectionContext = createContext<(agentId: string) => void>(() => {});
export const useTaskAgentSelection = () => use(TaskAgentSelectionContext);
export const TaskAgentProvider = memo<TaskAgentProviderProps>(({ children }) => {
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.inbox);
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.taskAgent);
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
const taskAgentId = useAgentStore(builtinAgentSelectors.taskAgentId);
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const setActiveAgentId = useAgentStore((s) => s.setActiveAgentId);
const activeTopicId = useChatStore((s) => s.activeTopicId);
const syncedAgentIdRef = useRef<string | undefined>(undefined);
const [scopedSelectedAgentId, setScopedSelectedAgentId] = useState<string | undefined>();
const detailMatch = useMatch('/task/:taskId');
const viewedTaskId = detailMatch?.params.taskId;
const selectedAgentId = scopedSelectedAgentId || taskAgentId;
const selectTaskAgent = useCallback((agentId: string) => {
if (!agentId || isChatGroupSessionId(agentId)) return;
setScopedSelectedAgentId(agentId);
}, []);
const selectedAgentId =
!activeAgentId || isChatGroupSessionId(activeAgentId) ? taskAgentId : activeAgentId;
useEffect(() => {
if (!selectedAgentId) return;
@@ -51,17 +43,17 @@ export const TaskAgentProvider = memo<TaskAgentProviderProps>(({ children }) =>
}
const chatState = useChatStore.getState();
const shouldSyncChatAgent = chatState.activeAgentId !== selectedAgentId;
const shouldResetTaskTopic = shouldSyncChatAgent || !!chatState.activeTopicId;
const shouldResetTopic =
chatState.activeAgentId !== selectedAgentId || !!chatState.activeTopicId;
if (shouldSyncChatAgent) {
if (chatState.activeAgentId !== selectedAgentId) {
useChatStore.setState({ activeAgentId: selectedAgentId });
}
if (!shouldSyncChatAgent && syncedAgentIdRef.current === selectedAgentId) return;
if (syncedAgentIdRef.current === selectedAgentId) return;
syncedAgentIdRef.current = selectedAgentId;
if (shouldResetTaskTopic) {
if (shouldResetTopic) {
void chatState.switchTopic(null, { scope: 'task', skipRefreshMessage: true });
}
}, [selectedAgentId, setActiveAgentId]);
@@ -85,19 +77,17 @@ export const TaskAgentProvider = memo<TaskAgentProviderProps>(({ children }) =>
if (!taskAgentId) return <Loading debugId="TaskAgentProvider" />;
return (
<TaskAgentSelectionContext value={selectTaskAgent}>
<ConversationProvider
context={context}
hasInitMessages={!!messages}
messages={messages}
operationState={operationState}
onMessagesChange={(msgs, ctx) => {
replaceMessages(msgs, { context: ctx });
}}
>
{children}
</ConversationProvider>
</TaskAgentSelectionContext>
<ConversationProvider
context={context}
hasInitMessages={!!messages}
messages={messages}
operationState={operationState}
onMessagesChange={(msgs, ctx) => {
replaceMessages(msgs, { context: ctx });
}}
>
{children}
</ConversationProvider>
);
});
@@ -7,12 +7,11 @@ import { useAgentDisplayMeta } from '../shared/useAgentDisplayMeta';
interface AssigneeAvatarProps {
agentId?: string | null;
fallbackToDefault?: boolean;
size?: number;
}
const AssigneeAvatar = memo<AssigneeAvatarProps>(({ agentId, fallbackToDefault, size = 18 }) => {
const displayMeta = useAgentDisplayMeta(agentId, { fallbackToDefault });
const AssigneeAvatar = memo<AssigneeAvatarProps>(({ agentId, size = 18 }) => {
const displayMeta = useAgentDisplayMeta(agentId);
if (!displayMeta) {
return (
@@ -16,10 +16,6 @@ interface AgentDisplayMeta {
title: string;
}
interface UseAgentDisplayMetaOptions {
fallbackToDefault?: boolean;
}
/**
* Resolves agent display metadata from agent store with sidebar data as fallback.
* The agent store only contains agents the user has actively visited, so sidebar
@@ -27,7 +23,6 @@ interface UseAgentDisplayMetaOptions {
*/
export const useAgentDisplayMeta = (
agentId: string | null | undefined,
{ fallbackToDefault = true }: UseAgentDisplayMetaOptions = {},
): AgentDisplayMeta | undefined => {
const { t } = useTranslation(['chat', 'common']);
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
@@ -40,10 +35,6 @@ export const useAgentDisplayMeta = (
const isInbox = isInboxAgentId(agentId, inboxAgentId);
const sidebarAvatar = typeof sidebarAgent?.avatar === 'string' ? sidebarAgent.avatar : undefined;
const hasResolvedMeta =
isInbox || !!meta?.avatar || !!meta?.backgroundColor || !!meta?.title?.trim() || !!sidebarAgent;
if (!fallbackToDefault && !hasResolvedMeta) return undefined;
return {
avatar: meta?.avatar || sidebarAvatar || (isInbox ? DEFAULT_INBOX_AVATAR : DEFAULT_AVATAR),
@@ -1,224 +0,0 @@
'use client';
import { Github } from '@lobehub/icons';
import { Flexbox, Icon, Popover } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { CheckIcon, ChevronDownIcon, SquircleDashed } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { getPendingTopicRepos, setPendingTopicRepos } from '@/store/chat/pendingTopicRepos';
import { topicSelectors } from '@/store/chat/selectors';
const styles = createStaticStyles(({ css }) => ({
button: css`
cursor: pointer;
display: flex;
gap: 6px;
align-items: center;
padding-block: 2px;
padding-inline: 4px;
border-radius: 4px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
transition: background 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
checkIndicator: css`
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1.5px solid ${cssVar.colorBorder};
border-radius: 4px;
`,
checkIndicatorChecked: css`
border-color: ${cssVar.colorPrimary};
color: #fff;
background: ${cssVar.colorPrimary};
`,
repoItem: css`
cursor: pointer;
padding-block: 6px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadius};
transition: background-color 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
repoName: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorText};
`,
repoUrl: css`
overflow: hidden;
font-size: 11px;
color: ${cssVar.colorTextDescription};
text-overflow: ellipsis;
white-space: nowrap;
`,
scrollContainer: css`
overflow-y: auto;
max-height: 360px;
`,
sectionTitle: css`
padding-block: 6px 2px;
padding-inline: 8px;
font-size: 11px;
font-weight: 500;
color: ${cssVar.colorTextQuaternary};
text-transform: uppercase;
letter-spacing: 0.5px;
`,
}));
const getRepoName = (repo: string) => repo.split('/').findLast(Boolean) || repo;
interface CloudRepoSwitcherProps {
agentId: string;
}
const CloudRepoSwitcher = memo<CloudRepoSwitcherProps>(({ agentId }) => {
const { t } = useTranslation('chat');
const [open, setOpen] = useState(false);
// Incremented to trigger re-renders when the module-singleton pending selection changes.
const [, forceUpdate] = useState(0);
// Available repos configured on the agent
const availableRepos: string[] = useAgentStore((s) => {
const env = agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider?.env;
try {
return JSON.parse(env?.GITHUB_REPOS ?? '[]');
} catch {
return [];
}
});
// Repos persisted to the current topic (empty when no topic or none set)
const topicRepos: string[] = useChatStore((s) => {
const meta = topicSelectors.currentTopicMetadata(s);
return meta?.repos ?? [];
});
const activeTopicId = useChatStore((s) => s.activeTopicId);
const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata);
const currentWorkingDirectory = useChatStore(
(s) => topicSelectors.currentTopicMetadata(s)?.workingDirectory,
);
const toggleRepo = useCallback(
async (repo: string) => {
if (!activeTopicId) {
// No topic yet — buffer in the module singleton keyed by agentId.
// gateway.ts will read and consume this when the first message creates a topic.
const prev = getPendingTopicRepos(agentId);
const isSelected = prev.includes(repo);
const next = isSelected ? prev.filter((r) => r !== repo) : [...prev, repo];
setPendingTopicRepos(agentId, next);
forceUpdate((v) => v + 1);
return;
}
const isSelected = topicRepos.includes(repo);
const nextRepos = isSelected ? topicRepos.filter((r) => r !== repo) : [...topicRepos, repo];
// Only set workingDirectory when it hasn't been assigned yet (first selection).
// Once set, it stays fixed so the topic keeps its sidebar grouping.
const patch: { repos: string[]; workingDirectory?: string } = { repos: nextRepos };
if (!currentWorkingDirectory && nextRepos.length > 0) {
patch.workingDirectory = nextRepos[0];
}
await updateTopicMetadata(activeTopicId, patch);
},
[agentId, activeTopicId, currentWorkingDirectory, topicRepos, updateTopicMetadata],
);
if (availableRepos.length === 0) return null;
// When a topic exists, show its persisted repos.
// When no topic exists yet, show the pending selection from the module singleton.
const displayRepos = activeTopicId ? topicRepos : getPendingTopicRepos(agentId);
// Button label
const buttonLabel =
displayRepos.length === 0
? t('heteroAgent.cloudRepo.notSet')
: displayRepos.length === 1
? getRepoName(displayRepos[0])
: t('heteroAgent.cloudRepo.multiSelected', { count: displayRepos.length });
const content = (
<Flexbox gap={4} style={{ minWidth: 280 }}>
<div className={styles.sectionTitle}>{t('heteroAgent.cloudRepo.sectionTitle')}</div>
<div className={styles.scrollContainer}>
{availableRepos.map((repo) => {
const isChecked = displayRepos.includes(repo);
return (
<Flexbox
horizontal
align="center"
className={styles.repoItem}
gap={8}
key={repo}
onClick={() => toggleRepo(repo)}
>
<div
className={`${styles.checkIndicator} ${isChecked ? styles.checkIndicatorChecked : ''}`}
>
{isChecked && <Icon icon={CheckIcon} size={12} />}
</div>
<Github size={16} style={{ color: cssVar.colorTextTertiary, flex: 'none' }} />
<Flexbox flex={1} style={{ minWidth: 0 }}>
<div className={styles.repoName}>{getRepoName(repo)}</div>
<div className={styles.repoUrl}>{repo}</div>
</Flexbox>
</Flexbox>
);
})}
</div>
</Flexbox>
);
return (
<Popover
content={content}
open={open}
placement="bottomLeft"
styles={{ content: { padding: 4 } }}
trigger="click"
onOpenChange={setOpen}
>
<div className={styles.button}>
{displayRepos.length > 0 ? <Github size={14} /> : <Icon icon={SquircleDashed} size={14} />}
<span>{buttonLabel}</span>
<Icon icon={ChevronDownIcon} size={12} />
</div>
</Popover>
);
});
CloudRepoSwitcher.displayName = 'CloudRepoSwitcher';
export default CloudRepoSwitcher;
+1 -10
View File
@@ -23,7 +23,6 @@ import { topicSelectors } from '@/store/chat/selectors';
import { useAgentId } from '../hooks/useAgentId';
import { useUpdateAgentConfig } from '../hooks/useUpdateAgentConfig';
import ApprovalMode from './ApprovalMode';
import CloudRepoSwitcher from './CloudRepoSwitcher';
import GitStatus from './GitStatus';
import { useRepoType } from './useRepoType';
import WorkingDirectory from './WorkingDirectory';
@@ -105,10 +104,9 @@ const RuntimeConfig = memo(() => {
const [dirPopoverOpen, setDirPopoverOpen] = useState(false);
const [modePopoverOpen, setModePopoverOpen] = useState(false);
const [isLoading, runtimeMode, isHeterogeneous] = useAgentStore((s) => [
const [isLoading, runtimeMode] = useAgentStore((s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false,
]);
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
@@ -229,13 +227,6 @@ const RuntimeConfig = memo(() => {
);
const rightContent = () => {
// Web + heterogeneous agent always shows the cloud repo switcher,
// regardless of the stored runtimeMode (which may be 'local' from desktop).
if (!isDesktop && isHeterogeneous && agentId) {
return <CloudRepoSwitcher agentId={agentId} />;
}
// Desktop local mode: show working directory picker
if (runtimeMode === 'local') {
return (
<>
+3 -189
View File
@@ -1,20 +1,8 @@
'use client';
import { ActionIcon, Button, Flexbox, Text, TextArea } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { CheckIcon, PencilIcon, XIcon } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { useDocumentStore } from '@/store/document';
import {
getSkillMarkdownMetadataError,
parseSkillMarkdownFrontmatterFields,
parseSkillMarkdownMetadata,
} from '@/utils/skillMarkdown';
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import EditorCanvas from './EditorCanvas';
import TodoList from './TodoList';
@@ -25,38 +13,6 @@ const styles = createStaticStyles(({ css }) => ({
flex: 1;
padding-inline: 16px;
`,
frontmatter: css`
margin-block: 16px 12px;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 8px;
background: ${cssVar.colorBgContainer};
`,
metadataKey: css`
flex-shrink: 0;
width: 112px;
font-family: ${cssVar.fontFamilyCode};
color: ${cssVar.colorTextSecondary};
`,
metadataRow: css`
padding-block: 10px;
padding-inline: 12px;
&:not(:last-child) {
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
}
`,
metadataValue: css`
min-width: 0;
white-space: pre-wrap;
`,
sectionHeader: css`
padding-block: 10px;
padding-inline: 12px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
textArea: css`
font-family: ${cssVar.fontFamilyCode};
`,
todoContainer: css`
flex-shrink: 0;
padding-block-end: 12px;
@@ -64,152 +20,10 @@ const styles = createStaticStyles(({ css }) => ({
`,
}));
interface SkillFrontmatterBlockProps {
documentId: string;
frontmatter: string;
}
const SkillFrontmatterBlock = memo<SkillFrontmatterBlockProps>(({ documentId, frontmatter }) => {
const { t } = useTranslation('editor');
const metadata = useMemo(() => parseSkillMarkdownMetadata(frontmatter), [frontmatter]);
const currentName = useMemo(
() => parseSkillMarkdownFrontmatterFields(frontmatter).name,
[frontmatter],
);
const [draft, setDraft] = useState(frontmatter);
const [error, setError] = useState<string>();
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [performSave, updateSkillFrontmatter] = useDocumentStore((s) => [
s.performSave,
s.updateSkillFrontmatter,
]);
useEffect(() => {
if (editing) return;
setDraft(frontmatter);
}, [editing, frontmatter]);
const handleEdit = useCallback(() => {
setDraft(frontmatter);
setError(undefined);
setEditing(true);
}, [frontmatter]);
const handleCancel = useCallback(() => {
setDraft(frontmatter);
setError(undefined);
setEditing(false);
}, [frontmatter]);
const handleSave = useCallback(async () => {
const nextError = getSkillMarkdownMetadataError(draft, { expectedName: currentName });
if (nextError) {
const message =
nextError.type === 'nameLocked'
? t(`skillFrontmatter.invalid.${nextError.type}`, { name: nextError.expectedName })
: t(`skillFrontmatter.invalid.${nextError.type}`);
setError(message);
return;
}
setSaving(true);
try {
const updated = updateSkillFrontmatter(documentId, draft);
if (!updated) {
setError(t('skillFrontmatter.saveFailed'));
return;
}
await performSave(documentId, undefined, { saveSource: 'manual' });
const latestDocument = useDocumentStore.getState().documents[documentId];
if (latestDocument?.isDirty) {
setError(t('skillFrontmatter.saveFailed'));
return;
}
setEditing(false);
setError(undefined);
} finally {
setSaving(false);
}
}, [currentName, documentId, draft, performSave, t, updateSkillFrontmatter]);
return (
<Flexbox className={styles.frontmatter}>
<Flexbox horizontal align="center" className={styles.sectionHeader} justify="space-between">
<Text type="secondary">{t('skillFrontmatter.title')}</Text>
{editing ? (
<Flexbox horizontal gap={8}>
<Button icon={XIcon} size="small" variant="outlined" onClick={handleCancel}>
{t('cancel')}
</Button>
<Button
icon={CheckIcon}
loading={saving}
size="small"
type="primary"
onClick={handleSave}
>
{t('confirm')}
</Button>
</Flexbox>
) : (
<ActionIcon
icon={PencilIcon}
size="small"
title={t('skillFrontmatter.edit')}
onClick={handleEdit}
/>
)}
</Flexbox>
{editing ? (
<Flexbox gap={8} padding={12}>
{/* Raw YAML is only exposed in edit mode so users can keep advanced frontmatter syntax. */}
<TextArea
autoSize={{ maxRows: 12, minRows: 4 }}
className={styles.textArea}
value={draft}
variant="borderless"
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
setDraft(event.target.value);
setError(undefined);
}}
/>
{error && <Text type="danger">{error}</Text>}
</Flexbox>
) : metadata.length > 0 ? (
metadata.map((item) => (
<Flexbox horizontal align="flex-start" className={styles.metadataRow} key={item.key}>
<Text className={styles.metadataKey}>{item.key}</Text>
<Text className={styles.metadataValue}>{item.value}</Text>
</Flexbox>
))
) : (
<Flexbox className={styles.metadataRow}>
<Text type="secondary">{t('skillFrontmatter.empty')}</Text>
</Flexbox>
)}
</Flexbox>
);
});
const DocumentBody = memo(() => {
const documentId = useChatStore(chatPortalSelectors.portalDocumentId);
const [skillFrontmatter, contentFormat] = useDocumentStore((s) =>
documentId
? [s.documents[documentId]?.skillFrontmatter ?? '', s.documents[documentId]?.contentFormat]
: ['', undefined],
);
const isSkillMarkdown = contentFormat === 'skillMarkdown';
return (
<Flexbox flex={1} height={'100%'} style={{ overflow: 'hidden' }}>
<div className={styles.content}>
{documentId && isSkillMarkdown && (
<SkillFrontmatterBlock documentId={documentId} frontmatter={skillFrontmatter} />
)}
<EditorCanvas />
</div>
<div className={styles.todoContainer}>
@@ -51,7 +51,7 @@ describe('task template analytics helpers', () => {
recommendationBatchId: 'batch-1',
template: makeTemplate({
fallbackPool: 'all_candidates',
optionalSkills: [{ provider: 'google-calendar', source: 'klavis' }],
optionalSkills: [{ provider: 'notion', source: 'klavis' }],
requiresSkills: [{ provider: 'github', source: 'lobehub' }],
source: 'fallback',
}),
@@ -10,15 +10,9 @@ describe('getProviderMeta', () => {
expect(meta?.icon).toBeDefined();
});
it('resolves notion as a lobehub source provider', () => {
const meta = getProviderMeta({ provider: 'notion', source: 'lobehub' });
expect(meta).toMatchObject({ label: 'Notion', provider: 'notion', source: 'lobehub' });
expect(meta?.icon).toBeDefined();
});
it('resolves klavis source via KLAVIS_SERVER_TYPES', () => {
const meta = getProviderMeta({ provider: 'gmail', source: 'klavis' });
expect(meta).toMatchObject({ label: 'Gmail', provider: 'gmail', source: 'klavis' });
const meta = getProviderMeta({ provider: 'notion', source: 'klavis' });
expect(meta).toMatchObject({ label: 'Notion', provider: 'notion', source: 'klavis' });
expect(meta?.icon).toBeDefined();
});
@@ -45,7 +39,7 @@ describe('findNextUnconnectedSpec', () => {
it('returns undefined when all specs are connected', () => {
const specs: TaskTemplateSkillRequirement[] = [
{ provider: 'github', source: 'lobehub' },
{ provider: 'notion', source: 'lobehub' },
{ provider: 'notion', source: 'klavis' },
];
expect(findNextUnconnectedSpec(specs, allConnected)).toBeUndefined();
});
@@ -53,7 +47,7 @@ describe('findNextUnconnectedSpec', () => {
it('returns the first spec when none are connected', () => {
const specs: TaskTemplateSkillRequirement[] = [
{ provider: 'github', source: 'lobehub' },
{ provider: 'notion', source: 'lobehub' },
{ provider: 'notion', source: 'klavis' },
];
const result = findNextUnconnectedSpec(specs, noneConnected);
expect(result?.provider).toBe('github');
@@ -64,19 +58,19 @@ describe('findNextUnconnectedSpec', () => {
const specs: TaskTemplateSkillRequirement[] = [
{ provider: 'github', source: 'lobehub' },
{ provider: 'linear', source: 'lobehub' },
{ provider: 'notion', 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('lobehub');
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: 'lobehub' },
{ provider: 'notion', source: 'klavis' },
];
const result = findNextUnconnectedSpec(specs, noneConnected);
expect(result?.provider).toBe('notion');
-1
View File
@@ -138,7 +138,6 @@ export const validateOIDCJWT = async (token: string) => {
exp: payload.exp,
iat: payload.iat,
jti: payload.jti,
purpose: payload.purpose as string | undefined,
scope: payload.scope,
sub: userId,
},
-4
View File
@@ -11,7 +11,6 @@
import { openTelemetry } from '../middleware/openTelemetry';
import { userAuth } from '../middleware/userAuth';
import { trpc } from './init';
import { heteroOperationAuth } from './middleware/heteroOperationAuth';
import { oidcAuth } from './middleware/oidcAuth';
/**
@@ -31,9 +30,6 @@ export const publicProcedure = baseProcedure;
// procedure that asserts that the user is logged in
export const authedProcedure = baseProcedure.use(oidcAuth).use(userAuth);
// procedure for hetero-agent ingest/finish endpoints — requires a `hetero-operation` JWT
export const heteroAuthedProcedure = baseProcedure.use(heteroOperationAuth).use(userAuth);
/**
* Create a server-side caller
* @link https://trpc.io/docs/v11/server/server-side-calls
@@ -1,52 +0,0 @@
import { TRPCError } from '@trpc/server';
import { describe, expect, it } from 'vitest';
import { createCallerFactory } from '@/libs/trpc/lambda';
import { trpc } from '@/libs/trpc/lambda/init';
import { heteroOperationAuth } from '../heteroOperationAuth';
// Minimal router that exercises heteroOperationAuth
const testRouter = trpc.router({
ping: trpc.procedure.use(heteroOperationAuth).query(({ ctx }) => ctx.userId),
});
const createCaller = createCallerFactory(testRouter);
describe('heteroOperationAuth middleware', () => {
it('passes through and exposes userId when purpose is hetero-operation', async () => {
const caller = createCaller({
oidcAuth: { purpose: 'hetero-operation', sub: 'user-abc' },
} as any);
const result = await caller.ping();
expect(result).toBe('user-abc');
});
it('rejects when oidcAuth is absent', async () => {
const caller = createCaller({} as any);
await expect(caller.ping()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
it('rejects a cli-sandbox token (wrong purpose)', async () => {
const caller = createCaller({
oidcAuth: { purpose: 'cli-sandbox', sub: 'user-abc' },
} as any);
await expect(caller.ping()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
it('rejects when purpose is undefined', async () => {
const caller = createCaller({
oidcAuth: { sub: 'user-abc' },
} as any);
await expect(caller.ping()).rejects.toThrow(TRPCError);
});
});
@@ -1,55 +0,0 @@
import { TRPCError } from '@trpc/server';
import { describe, expect, it } from 'vitest';
import { createCallerFactory } from '@/libs/trpc/lambda';
import { trpc } from '@/libs/trpc/lambda/init';
import { oidcAuth } from '../oidcAuth';
// Minimal router that exercises oidcAuth
const testRouter = trpc.router({
ping: trpc.procedure.use(oidcAuth).query(({ ctx }) => ctx.userId ?? null),
});
const createCaller = createCallerFactory(testRouter);
describe('oidcAuth middleware', () => {
it('sets userId from sub when oidcAuth is a normal token', async () => {
const caller = createCaller({
oidcAuth: { purpose: 'cli-sandbox', sub: 'user-abc' },
} as any);
const result = await caller.ping();
expect(result).toBe('user-abc');
});
it('sets userId when oidcAuth has no purpose field (standard OIDC token)', async () => {
const caller = createCaller({
oidcAuth: { sub: 'user-abc' },
} as any);
const result = await caller.ping();
expect(result).toBe('user-abc');
});
it('passes through with null userId when oidcAuth is absent', async () => {
const caller = createCaller({} as any);
const result = await caller.ping();
expect(result).toBeNull();
});
it('rejects hetero-operation tokens to block misuse on normal authed routes', async () => {
const caller = createCaller({
oidcAuth: { purpose: 'hetero-operation', sub: 'user-abc' },
} as any);
await expect(caller.ping()).rejects.toThrow(TRPCError);
await expect(caller.ping()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
});
@@ -1,25 +0,0 @@
import { TRPCError } from '@trpc/server';
import { trpc } from '../init';
/**
* Auth middleware for hetero-agent ingest/finish endpoints.
* Accepts ONLY tokens signed with `purpose: 'hetero-operation'` (4h expiry).
* All other tokens — including normal user OIDC tokens — are rejected,
* so this procedure cannot be called from the browser or CLI without the
* dedicated operation JWT issued by execAgent.
*/
export const heteroOperationAuth = trpc.middleware(async (opts) => {
const { ctx, next } = opts;
if (!ctx.oidcAuth || ctx.oidcAuth.purpose !== 'hetero-operation') {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'This endpoint requires a hetero-operation token',
});
}
return next({
ctx: { oidcAuth: ctx.oidcAuth, userId: ctx.oidcAuth.sub as string },
});
});
-1
View File
@@ -1,4 +1,3 @@
export * from './heteroOperationAuth';
export * from './marketSDK';
export * from './marketUserInfo';
export * from './serverDatabase';
@@ -1,5 +1,3 @@
import { TRPCError } from '@trpc/server';
import { trpc } from '../init';
export const oidcAuth = trpc.middleware(async (opts) => {
@@ -7,16 +5,6 @@ export const oidcAuth = trpc.middleware(async (opts) => {
// Check OIDC authentication
if (ctx.oidcAuth) {
// hetero-operation tokens are long-lived (4h) and scoped exclusively to
// heteroIngest / heteroFinish. Reject them here so a leaked sandbox JWT
// cannot be replayed against any other authed route.
if (ctx.oidcAuth.purpose === 'hetero-operation') {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'hetero-operation tokens are not accepted on this endpoint',
});
}
return next({
ctx: { oidcAuth: ctx.oidcAuth, userId: ctx.oidcAuth.sub },
});
@@ -1,154 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Must mock authEnv before importing the module under test so getJwksKey() resolves.
vi.mock('@/envs/auth', () => ({
authEnv: {
INTERNAL_JWT_EXPIRATION: '30s',
JWKS_KEY: JSON.stringify({
keys: [
{
alg: 'RS256',
d: 'private-d',
dp: 'private-dp',
dq: 'private-dq',
e: 'AQAB',
kid: 'test-kid',
kty: 'RSA',
n: 'test-modulus',
p: 'private-p',
q: 'private-q',
qi: 'private-qi',
use: 'sig',
},
],
}),
},
}));
// Mock jose so we never need real RSA keys.
// SignJWT is a class with a fluent builder API — every setter must return `this`
// so the chain (.setProtectedHeader().setSubject()…) doesn't break.
const signMock = vi.fn().mockResolvedValue('signed.jwt.token');
const setExpirationTimeMock = vi.fn();
const setIssuedAtMock = vi.fn();
const setSubjectMock = vi.fn();
const setProtectedHeaderMock = vi.fn();
const buildSignJWTChain = () => {
const chain = {
setExpirationTime: setExpirationTimeMock.mockReturnValue(undefined as any),
setIssuedAt: setIssuedAtMock.mockReturnValue(undefined as any),
setProtectedHeader: setProtectedHeaderMock.mockReturnValue(undefined as any),
setSubject: setSubjectMock.mockReturnValue(undefined as any),
sign: signMock,
};
// Make every setter return the same chain object so .method().method() works.
setProtectedHeaderMock.mockReturnValue(chain);
setSubjectMock.mockReturnValue(chain);
setIssuedAtMock.mockReturnValue(chain);
setExpirationTimeMock.mockReturnValue(chain);
return chain;
};
const SignJWTMock = vi.fn();
const importJWKMock = vi.fn().mockResolvedValue('mock-crypto-key');
vi.mock('jose', () => ({
SignJWT: SignJWTMock,
importJWK: (...args: unknown[]) => importJWKMock(...args),
jwtVerify: vi.fn(),
}));
describe('internalJwt', () => {
beforeEach(() => {
vi.clearAllMocks();
importJWKMock.mockResolvedValue('mock-crypto-key');
signMock.mockResolvedValue('signed.jwt.token');
SignJWTMock.mockImplementation(() => buildSignJWTChain());
});
describe('signUserJWT', () => {
it('signs a JWT with 5-minute expiry and cli-sandbox purpose', async () => {
const { signUserJWT } = await import('../internalJwt');
const token = await signUserJWT('user-123');
expect(token).toBe('signed.jwt.token');
expect(SignJWTMock).toHaveBeenCalledWith({ purpose: 'cli-sandbox' });
expect(setSubjectMock).toHaveBeenCalledWith('user-123');
expect(setExpirationTimeMock).toHaveBeenCalledWith('5m');
expect(signMock).toHaveBeenCalledWith('mock-crypto-key');
});
it('sets the protected header with RS256 and the key id', async () => {
const { signUserJWT } = await import('../internalJwt');
await signUserJWT('user-abc');
expect(setProtectedHeaderMock).toHaveBeenCalledWith({ alg: 'RS256', kid: 'test-kid' });
});
it('calls setIssuedAt to stamp the creation time', async () => {
const { signUserJWT } = await import('../internalJwt');
await signUserJWT('user-abc');
expect(setIssuedAtMock).toHaveBeenCalled();
});
});
describe('signOperationJwt', () => {
it('signs a JWT with 4-hour expiry and hetero-operation purpose', async () => {
const { signOperationJwt } = await import('../internalJwt');
const token = await signOperationJwt('user-456');
expect(token).toBe('signed.jwt.token');
expect(SignJWTMock).toHaveBeenCalledWith({ purpose: 'hetero-operation' });
expect(setSubjectMock).toHaveBeenCalledWith('user-456');
expect(setExpirationTimeMock).toHaveBeenCalledWith('4h');
expect(signMock).toHaveBeenCalledWith('mock-crypto-key');
});
it('sets the protected header with RS256 and the key id', async () => {
const { signOperationJwt } = await import('../internalJwt');
await signOperationJwt('user-456');
expect(setProtectedHeaderMock).toHaveBeenCalledWith({ alg: 'RS256', kid: 'test-kid' });
});
it('calls setIssuedAt to stamp the creation time', async () => {
const { signOperationJwt } = await import('../internalJwt');
await signOperationJwt('user-456');
expect(setIssuedAtMock).toHaveBeenCalled();
});
it('uses a longer expiry than signUserJWT (4h vs 5m)', async () => {
const { signOperationJwt, signUserJWT } = await import('../internalJwt');
await signUserJWT('user-a');
const userExpiry = setExpirationTimeMock.mock.calls.at(-1)?.[0];
await signOperationJwt('user-b');
const opExpiry = setExpirationTimeMock.mock.calls.at(-1)?.[0];
expect(userExpiry).toBe('5m');
expect(opExpiry).toBe('4h');
});
});
describe('signInternalJWT', () => {
it('signs a JWT with the internal purpose from env expiry', async () => {
const { signInternalJWT } = await import('../internalJwt');
const token = await signInternalJWT();
expect(token).toBe('signed.jwt.token');
expect(SignJWTMock).toHaveBeenCalledWith({ purpose: 'lobe-internal-call' });
expect(setExpirationTimeMock).toHaveBeenCalledWith('30s');
});
});
});
-16
View File
@@ -95,22 +95,6 @@ export const signUserJWT = async (userId: string): Promise<string> => {
.sign(key);
};
/**
* Sign a long-lived OIDC-compatible JWT for hetero-agent operations.
* Claude Code / Codex tasks can run for hours; this 4-hour token prevents
* heteroIngest / heteroFinish from returning 401 mid-execution.
*/
export const signOperationJwt = async (userId: string): Promise<string> => {
const { key, kid } = await getSigningKey();
return new SignJWT({ purpose: 'hetero-operation' })
.setProtectedHeader({ alg: 'RS256', kid })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime('4h')
.sign(key);
};
/**
* Validate internal JWT from lambda → async calls
* Returns true if valid, false otherwise
-4
View File
@@ -166,10 +166,6 @@ export default {
'Claude Code sessions are pinned to a working directory. Switching will start a new session for this topic — chat messages stay, but the previous session context cannot be resumed.',
'heteroAgent.switchCwd.ok': 'Switch and start new session',
'heteroAgent.switchCwd.title': 'Switch working directory?',
'heteroAgent.cloudRepo.sectionTitle': 'Repositories',
'heteroAgent.cloudRepo.notSet': 'No repo selected',
'heteroAgent.cloudRepo.noRepos': 'No repositories configured. Add them in agent settings.',
'heteroAgent.cloudRepo.multiSelected': '{{count}} repos selected',
'hideForYou':
"Direct message content is hidden. Please enable 'Show Direct Message Content' in settings to view.",
'history.title': 'The Agent will keep only the latest {{count}} messages.',
+1 -1
View File
@@ -32,7 +32,7 @@ export default {
'batchDelete': 'Batch Delete',
'blog': 'Product Blog',
'botIntegrationBanner.dismiss': 'Dismiss',
'botIntegrationBanner.title': 'Talk to Lobe AI on your favorite messaging apps.',
'botIntegrationBanner.title': 'Add Channels to Lobe AI',
'branching': 'Create Subtopic',
'branchingDisable':
'The "Sub-topic" feature is unavailable in the current mode. To use this feature, please switch to Postgres/Pglite DB mode or use LobeHub Cloud.',
-13
View File
@@ -52,19 +52,6 @@ export default {
'slash.table': 'Table',
'slash.tex': 'TeX Formula',
'slash.translate': 'Translate',
'skillFrontmatter.edit': 'Edit metadata',
'skillFrontmatter.empty': 'No metadata',
'skillFrontmatter.invalid.descriptionInvalid': 'Description must be single-line text.',
'skillFrontmatter.invalid.descriptionRequired': 'Description is required.',
'skillFrontmatter.invalid.mapping': 'Frontmatter must be a YAML mapping.',
'skillFrontmatter.invalid.nameInvalid': 'Name must use lowercase letters, numbers, and hyphens.',
'skillFrontmatter.invalid.nameLocked':
'Name must remain {{name}}. Rename the skill bundle instead.',
'skillFrontmatter.invalid.nameRequired': 'Name is required.',
'skillFrontmatter.invalid.required': 'Frontmatter is required.',
'skillFrontmatter.invalid.syntax': 'Invalid YAML syntax.',
'skillFrontmatter.saveFailed': 'Metadata was not saved. Retry, or keep editing.',
'skillFrontmatter.title': 'Skill metadata',
'table.delete': 'Delete table',
'table.deleteColumn': 'Delete column',
'table.deleteRow': 'Delete row',
+1 -1
View File
@@ -697,7 +697,7 @@ export default {
'skillDetail.trustWarning':
"Only use connectors from developers you trust. LobeHub does not control which tools developers make available and cannot verify that they will work as intended or that they won't change.",
'skillInstallBanner.dismiss': 'Dismiss',
'skillInstallBanner.title': 'Connect your favorite apps to Lobe AI.',
'skillInstallBanner.title': 'Add skills to Lobe AI',
'store.actions.cancel': 'Cancel',
'store.actions.configure': 'Configure',
'store.actions.confirmUninstall': 'Uninstalling will clear Skill config. Continue?',
-29
View File
@@ -219,31 +219,6 @@ export default {
'heterogeneousStatus.plan.label': 'Plan',
'heterogeneousStatus.redetect': 'Re-detect',
'heterogeneousStatus.unavailable': '{{name}} CLI not found. Please install or configure it.',
// Heterogeneous agent — Cloud tab (web environment config)
'heterogeneousStatus.cloud.tabLabel': 'Cloud',
'heterogeneousStatus.cloud.tokenLabel': 'Claude Code Token',
'heterogeneousStatus.cloud.tokenDesc':
'Your Claude Code OAuth token. Saved securely to Credentials once submitted. Run `claude setup-token` in your terminal to generate one.',
'heterogeneousStatus.cloud.tokenPlaceholder': 'Paste your OAuth token here',
'heterogeneousStatus.cloud.tokenChange': 'Change',
'heterogeneousStatus.cloud.tokenSave': 'Save',
'heterogeneousStatus.cloud.tokenCancel': 'Cancel',
'heterogeneousStatus.cloud.githubLabel': 'GitHub Connection',
'heterogeneousStatus.cloud.githubDesc':
'Select a GitHub OAuth credential to allow the sandbox to clone your private repositories.',
'heterogeneousStatus.cloud.githubPlaceholder': 'Select a GitHub credential...',
'heterogeneousStatus.cloud.githubNoCreds': 'No GitHub credentials found.',
'heterogeneousStatus.cloud.manageCredentials': 'Manage Credentials →',
'heterogeneousStatus.cloud.repoLabel': 'Repositories',
'heterogeneousStatus.cloud.repoDesc':
'Add repositories to the list. Switch the active one from the bottom bar in the chat view.',
'heterogeneousStatus.cloud.repoPlaceholder': 'owner/repo or https://github.com/owner/repo',
'heterogeneousStatus.cloud.repoAdd': 'Add',
// Heterogeneous agent — Desktop tab
'heterogeneousStatus.desktop.tabLabel': 'Desktop',
'checking': 'Checking...',
// Credentials Management
@@ -1343,10 +1318,6 @@ When I am ___, I need ___
'Outlook Calendar is an integrated scheduling tool within Microsoft Outlook that enables users to create appointments, organize meetings with others, and manage their time and events effectively.',
'tools.lobehubSkill.providers.microsoft.readme':
'Integrate with Outlook Calendar to view, create, and manage your events seamlessly. Schedule meetings, check availability, set reminders, and coordinate your time—all through natural language commands.',
'tools.lobehubSkill.providers.notion.description':
'Notion is a collaborative productivity and note-taking application.',
'tools.lobehubSkill.providers.notion.readme':
'Connect to Notion to access and manage your workspace. Create pages, search content, update databases, and organize your knowledge base—all through natural conversation with your AI assistant.',
'tools.lobehubSkill.providers.twitter.description':
'X (Twitter) is a social media platform for sharing real-time updates, news, and engaging with your audience through posts, replies, and direct messages.',
'tools.lobehubSkill.providers.twitter.readme':
@@ -1 +0,0 @@
export { default } from '@/features/AgentTasks/TaskWorkspaceLayout';
@@ -1,6 +1,5 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Github } from '@lobehub/icons';
import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
@@ -15,7 +14,6 @@ import { memo, type ReactNode, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentId } from '@/features/ChatInput/hooks/useAgentId';
import CloudRepoSwitcher from '@/features/ChatInput/RuntimeConfig/CloudRepoSwitcher';
import GitStatus from '@/features/ChatInput/RuntimeConfig/GitStatus';
import { useRepoType } from '@/features/ChatInput/RuntimeConfig/useRepoType';
import WorkingDirectoryContent from '@/features/ChatInput/RuntimeConfig/WorkingDirectory';
@@ -71,7 +69,6 @@ const WorkingDirectoryBar = memo(() => {
const agentId = useAgentId();
const [open, setOpen] = useState(false);
// All hooks must be called unconditionally (Rules of Hooks)
const isLoading = useAgentStore(agentByIdSelectors.isAgentConfigLoadingById(agentId));
const agentWorkingDirectory = useAgentStore((s) =>
agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined,
@@ -88,16 +85,6 @@ const WorkingDirectoryBar = memo(() => {
return <Icon icon={FolderIcon} size={14} />;
}, [effectiveWorkingDirectory, repoType]);
// On web, show the cloud repo switcher instead of the local directory picker
if (!isDesktop) {
if (!agentId) return null;
return (
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
<CloudRepoSwitcher agentId={agentId} />
</Flexbox>
);
}
if (!agentId || isLoading) {
return (
<Flexbox horizontal align={'center'} className={styles.bar} gap={4} justify={'space-between'}>
@@ -1,384 +0,0 @@
'use client';
import { type HeterogeneousProviderConfig, type UserCredSummary } from '@lobechat/types';
import { Github } from '@lobehub/icons';
import { Flexbox } from '@lobehub/ui';
import { Avatar, Button, Input, Select, Spin, Tag, Typography } from 'antd';
import { createStyles } from 'antd-style';
import { CheckCircle2, KeyRound, X } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { lambdaClient, lambdaQuery } from '@/libs/trpc/client';
// Fixed cred key for Claude Code OAuth token — never changes
const CLAUDE_TOKEN_CRED_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
const useStyles = createStyles(({ css, token, cssVar }) => ({
card: css`
padding-block: 16px 12px;
padding-inline: 16px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
background: ${token.colorBgContainer};
`,
credOption: css`
display: flex;
gap: 8px;
align-items: center;
`,
manageLink: css`
cursor: pointer;
font-size: 12px;
color: ${cssVar.colorPrimary};
&:hover {
text-decoration: underline;
}
`,
repoItem: css`
cursor: pointer;
display: flex;
gap: 8px;
align-items: center;
min-height: 36px;
padding-block: 6px;
padding-inline: 8px;
border-radius: ${token.borderRadiusSM}px;
transition: background 0.15s;
&:hover {
background: ${token.colorFillTertiary};
.repo-delete-btn {
opacity: 1;
}
}
`,
repoItemActive: css`
background: ${token.colorFillSecondary};
`,
repoDeleteBtn: css`
cursor: pointer;
flex-shrink: 0;
margin-inline-start: auto;
padding: 2px;
border: none;
border-radius: 4px;
color: ${token.colorTextTertiary};
opacity: 0;
background: transparent;
transition:
opacity 0.15s,
color 0.15s;
&:hover {
color: ${token.colorError};
}
`,
repoList: css`
display: flex;
flex-direction: column;
gap: 2px;
`,
sectionDesc: css`
font-size: 12px;
color: ${token.colorTextSecondary};
`,
sectionDivider: css`
margin-block: 12px;
border-block-start: 1px solid ${token.colorBorderSecondary};
`,
sectionLabel: css`
font-size: 12px;
color: ${token.colorTextTertiary};
text-transform: uppercase;
letter-spacing: 0.04em;
`,
}));
interface CloudHeterogeneousConfigProps {
onEnvChange: (env: Record<string, string>) => Promise<void> | void;
provider: HeterogeneousProviderConfig;
}
// ── Claude Code Token section ──────────────────────────────────────────────
interface TokenSectionProps {
existingCred: UserCredSummary | undefined;
onEnvChange: (patch: Record<string, string>) => void;
onSaved: () => void;
}
const TokenSection = memo<TokenSectionProps>(({ existingCred, onSaved, onEnvChange }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const [editing, setEditing] = useState(!existingCred);
const [tokenInput, setTokenInput] = useState('');
const [saving, setSaving] = useState(false);
const handleSave = async () => {
const token = tokenInput.trim();
if (!token) return;
setSaving(true);
try {
await lambdaClient.market.creds.createKV.mutate({
key: CLAUDE_TOKEN_CRED_KEY,
name: 'Claude Code OAuth Token',
type: 'kv-env',
values: { [CLAUDE_TOKEN_CRED_KEY]: token },
});
onEnvChange({ CLAUDE_CODE_CRED_KEY: CLAUDE_TOKEN_CRED_KEY });
setTokenInput('');
setEditing(false);
onSaved();
} finally {
setSaving(false);
}
};
return (
<Flexbox gap={8}>
<Flexbox horizontal align="center" justify="space-between">
<Flexbox horizontal align="center" gap={6}>
<KeyRound size={12} />
<span className={styles.sectionLabel}>{t('heterogeneousStatus.cloud.tokenLabel')}</span>
</Flexbox>
{existingCred && !editing && (
<span className={styles.manageLink} onClick={() => setEditing(true)}>
{t('heterogeneousStatus.cloud.tokenChange')}
</span>
)}
</Flexbox>
{existingCred && !editing ? (
<Flexbox horizontal align="center" gap={8}>
<Tag
color="success"
icon={<CheckCircle2 size={11} />}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
{existingCred.maskedPreview ?? existingCred.name}
</Tag>
</Flexbox>
) : (
<Flexbox horizontal gap={8}>
<Input.Password
autoFocus={!!existingCred}
placeholder={t('heterogeneousStatus.cloud.tokenPlaceholder')}
style={{ flex: 1 }}
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
onPressEnter={handleSave}
/>
<Button loading={saving} type="primary" onClick={handleSave}>
{t('heterogeneousStatus.cloud.tokenSave')}
</Button>
{existingCred && (
<Button
onClick={() => {
setEditing(false);
setTokenInput('');
}}
>
{t('heterogeneousStatus.cloud.tokenCancel')}
</Button>
)}
</Flexbox>
)}
<span className={styles.sectionDesc}>{t('heterogeneousStatus.cloud.tokenDesc')}</span>
</Flexbox>
);
});
// ── Repo list section ──────────────────────────────────────────────────────
// Profile page: manage the list of repos (add / delete only).
// Active repo selection happens in the bottom-left CloudRepoSwitcher.
interface RepoListSectionProps {
onReposChange: (repos: string[]) => void;
repos: string[];
}
const RepoListSection = memo<RepoListSectionProps>(({ repos, onReposChange }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const [input, setInput] = useState('');
const addRepo = () => {
const v = input.trim();
if (!v || repos.includes(v)) return;
onReposChange([...repos, v]);
setInput('');
};
const removeRepo = (repo: string, e: React.MouseEvent) => {
e.stopPropagation();
onReposChange(repos.filter((r) => r !== repo));
};
return (
<Flexbox gap={8}>
<span className={styles.sectionLabel}>{t('heterogeneousStatus.cloud.repoLabel')}</span>
{repos.length > 0 && (
<div className={styles.repoList}>
{repos.map((repo) => (
<div className={styles.repoItem} key={repo}>
<Github size={14} style={{ flexShrink: 0 }} />
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>
{repo}
</Typography.Text>
<button
className={`${styles.repoDeleteBtn} repo-delete-btn`}
onClick={(e) => removeRepo(repo, e)}
>
<X size={12} />
</button>
</div>
))}
</div>
)}
<Flexbox horizontal gap={8}>
<Input
placeholder={t('heterogeneousStatus.cloud.repoPlaceholder')}
style={{ flex: 1 }}
value={input}
onChange={(e) => setInput(e.target.value)}
onPressEnter={addRepo}
/>
<Button onClick={addRepo}>{t('heterogeneousStatus.cloud.repoAdd')}</Button>
</Flexbox>
<span className={styles.sectionDesc}>{t('heterogeneousStatus.cloud.repoDesc')}</span>
</Flexbox>
);
});
// ── Main component ─────────────────────────────────────────────────────────
const CloudHeterogeneousConfig = memo<CloudHeterogeneousConfigProps>(
({ provider, onEnvChange }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const navigate = useNavigate();
const currentEnv = provider.env ?? {};
const storedGithubCredKey = currentEnv.GITHUB_CRED_KEY ?? '';
const repos: string[] = (() => {
try {
return JSON.parse(currentEnv.GITHUB_REPOS ?? '[]');
} catch {
return [];
}
})();
const {
data: credsData,
isLoading,
refetch,
} = lambdaQuery.market.creds.list.useQuery(undefined);
const allCreds: UserCredSummary[] = credsData?.data ?? [];
const claudeTokenCred = allCreds.find((c) => c.key === CLAUDE_TOKEN_CRED_KEY);
const githubCreds = allCreds.filter(
(c) => c.type === 'oauth' && c.oauthProvider?.toLowerCase().includes('github'),
);
const saveEnv = (patch: Record<string, string>) => {
void onEnvChange({ ...currentEnv, ...patch });
};
const handleReposChange = (nextRepos: string[]) => {
saveEnv({ GITHUB_REPOS: JSON.stringify(nextRepos) });
};
if (isLoading) {
return (
<Flexbox align="center" justify="center" style={{ paddingBlock: 32 }}>
<Spin size="small" />
</Flexbox>
);
}
return (
<div className={styles.card}>
<Flexbox gap={16}>
{/* ── Claude Code OAuth Token ── */}
<TokenSection
existingCred={claudeTokenCred}
onEnvChange={saveEnv}
onSaved={() => refetch()}
/>
<div className={styles.sectionDivider} />
{/* ── GitHub OAuth Credential ── */}
<Flexbox gap={8}>
<Flexbox horizontal align="center" justify="space-between">
<Flexbox horizontal align="center" gap={6}>
<Github size={12} />
<span className={styles.sectionLabel}>
{t('heterogeneousStatus.cloud.githubLabel')}
</span>
</Flexbox>
<span className={styles.manageLink} onClick={() => navigate('/settings/creds')}>
{t('heterogeneousStatus.cloud.manageCredentials')}
</span>
</Flexbox>
<Select
allowClear
placeholder={t('heterogeneousStatus.cloud.githubPlaceholder')}
style={{ width: '100%' }}
value={storedGithubCredKey || undefined}
notFoundContent={
<Flexbox style={{ padding: '8px 0', fontSize: 12 }}>
{t('heterogeneousStatus.cloud.githubNoCreds')}
</Flexbox>
}
onChange={(key: string) => saveEnv({ GITHUB_CRED_KEY: key })}
onClear={() => saveEnv({ GITHUB_CRED_KEY: '' })}
>
{githubCreds.map((cred) => (
<Select.Option key={cred.key} value={cred.key}>
<span className={styles.credOption}>
{cred.oauthAvatar ? (
<Avatar size={16} src={cred.oauthAvatar} />
) : (
<Github size={14} />
)}
<span>{cred.name}</span>
{cred.oauthUsername && (
<Typography.Text style={{ fontSize: 12 }} type="secondary">
@{cred.oauthUsername}
</Typography.Text>
)}
</span>
</Select.Option>
))}
</Select>
<span className={styles.sectionDesc}>{t('heterogeneousStatus.cloud.githubDesc')}</span>
</Flexbox>
<div className={styles.sectionDivider} />
{/* ── Repository list ── */}
<RepoListSection repos={repos} onReposChange={handleReposChange} />
</Flexbox>
</div>
);
},
);
export default CloudHeterogeneousConfig;
@@ -1,11 +1,9 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Flexbox } from '@lobehub/ui';
import { Divider, Tabs } from 'antd';
import { Divider } from 'antd';
import isEqual from 'fast-deep-equal';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ModelSelect from '@/features/ModelSelect';
import { useAgentStore } from '@/store/agent';
@@ -17,31 +15,23 @@ import AgentSettings from '../AgentSettings';
import EditorCanvas from '../EditorCanvas';
import AgentHeader from './AgentHeader';
import AgentTool from './AgentTool';
import CloudHeterogeneousConfig from './CloudHeterogeneousConfig';
import HeterogeneousAgentStatusCard from './HeterogeneousAgentStatusCard';
const ProfileEditor = memo(() => {
const { t } = useTranslation('setting');
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
const updateConfig = useAgentStore((s) => s.updateAgentConfig);
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
const heterogeneousProvider = config.agencyConfig?.heterogeneousProvider;
const updateHeterogeneousCommand = async (command: string) => {
if (!heterogeneousProvider) return;
await updateConfig({
agencyConfig: {
heterogeneousProvider: { ...heterogeneousProvider, command },
},
});
};
const updateHeterogeneousEnv = async (env: Record<string, string>) => {
if (!heterogeneousProvider) return;
await updateConfig({
agencyConfig: {
heterogeneousProvider: { ...heterogeneousProvider, env },
heterogeneousProvider: {
...heterogeneousProvider,
command,
},
},
});
};
@@ -57,33 +47,10 @@ const ProfileEditor = memo(() => {
{/* Header: Avatar + Name + Description */}
<AgentHeader />
{isHeterogeneous && heterogeneousProvider ? (
// Heterogeneous integration mode: tabs for cloud (web) and desktop environments
<Tabs
defaultActiveKey={isDesktop ? 'desktop' : 'cloud'}
size="small"
items={[
{
key: 'cloud',
label: t('heterogeneousStatus.cloud.tabLabel'),
children: (
<CloudHeterogeneousConfig
provider={heterogeneousProvider}
onEnvChange={updateHeterogeneousEnv}
/>
),
},
{
key: 'desktop',
label: t('heterogeneousStatus.desktop.tabLabel'),
disabled: !isDesktop,
children: (
<HeterogeneousAgentStatusCard
provider={heterogeneousProvider}
onCommandChange={updateHeterogeneousCommand}
/>
),
},
]}
// Heterogeneous integration mode: show provider CLI status instead of model/skills pickers
<HeterogeneousAgentStatusCard
provider={heterogeneousProvider}
onCommandChange={updateHeterogeneousCommand}
/>
) : (
<>
@@ -14,7 +14,7 @@ import { useToolStore } from '@/store/tool';
// Bump this id when the banner content changes so dismissing the old
// variant does not hide the new one.
export const SKILL_INSTALL_BANNER_ID = 'skill-install-v2';
export const SKILL_INSTALL_BANNER_ID = 'skill-install-v1';
const ICON_SIZE = 16;
const AVATAR_SIZE = 24;
@@ -74,9 +74,9 @@ const BANNER_SKILL_IDS = [
{ id: 'google-drive', type: 'klavis' },
{ id: 'google-calendar', type: 'klavis' },
{ id: 'slack', type: 'klavis' },
{ id: 'notion', type: 'lobehub' },
{ id: 'notion', type: 'klavis' },
{ id: 'twitter', type: 'lobehub' },
{ id: 'github', type: 'lobehub' },
{ id: 'github', type: 'klavis' },
] as const;
const SkillInstallBanner = memo(() => {
@@ -132,6 +132,13 @@ const Page = memo(() => {
]
: []),
{
avatar: (
<img
alt={tLabs('features.inputMarkdown.title')}
src="https://github.com/user-attachments/assets/0527a966-3d95-46b4-b880-c0f3fca18f02"
style={{ borderRadius: 8, height: 72, marginRight: 12, objectFit: 'cover', width: 120 }}
/>
),
children: (
<Switch
checked={enableInputMarkdown}
@@ -7,7 +7,7 @@ import { Outlet } from 'react-router-dom';
import AgentTaskManager from '@/features/AgentTaskManager';
import { useIsMobile } from '@/hooks/useIsMobile';
const TaskWorkspaceLayout = memo(() => {
const TaskDetailLayout = memo(() => {
const isMobile = useIsMobile();
return (
@@ -20,6 +20,6 @@ const TaskWorkspaceLayout = memo(() => {
);
});
TaskWorkspaceLayout.displayName = 'TaskWorkspaceLayout';
TaskDetailLayout.displayName = 'TaskDetailLayout';
export default TaskWorkspaceLayout;
export default TaskDetailLayout;
+25
View File
@@ -0,0 +1,25 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { Outlet } from 'react-router-dom';
import AgentTaskManager from '@/features/AgentTaskManager';
import { useIsMobile } from '@/hooks/useIsMobile';
const AllTasksLayout = memo(() => {
const isMobile = useIsMobile();
return (
<Flexbox flex={1} height={'100%'} horizontal={!isMobile} width={'100%'}>
<Flexbox flex={1} style={{ minWidth: 0 }}>
<Outlet />
</Flexbox>
{!isMobile && <AgentTaskManager />}
</Flexbox>
);
});
AllTasksLayout.displayName = 'AllTasksLayout';
export default AllTasksLayout;
@@ -61,12 +61,7 @@ describe('aiAgentRouter.heteroIngest / heteroFinish', () => {
vi.clearAllMocks();
});
const createCaller = () =>
aiAgentRouter.createCaller({
jwtPayload: { userId },
oidcAuth: { purpose: 'hetero-operation', sub: userId },
userId,
} as any);
const createCaller = () => aiAgentRouter.createCaller({ userId, jwtPayload: { userId } } as any);
describe('heteroIngest', () => {
it('delegates the batch to HeterogeneousAgentService and acks', async () => {
+3 -22
View File
@@ -11,7 +11,7 @@ import { z } from 'zod';
import { MessageModel } from '@/database/models/message';
import { ThreadModel } from '@/database/models/thread';
import { TopicModel } from '@/database/models/topic';
import { authedProcedure, heteroAuthedProcedure, router } from '@/libs/trpc/lambda';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { AgentRuntimeService } from '@/server/services/agentRuntime';
import { AiAgentService } from '@/server/services/aiAgent';
@@ -142,12 +142,6 @@ const ExecAgentSchema = z
defaultTaskAssigneeAgentId: z.string().optional(),
documentId: z.string().optional().nullable(),
groupId: z.string().optional().nullable(),
initialTopicMetadata: z
.object({
repos: z.array(z.string()).optional(),
workingDirectory: z.string().optional(),
})
.optional(),
scope: z.string().optional().nullable(),
sessionId: z.string().optional(),
taskId: z.string().optional().nullable(),
@@ -411,19 +405,6 @@ const aiAgentProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
});
});
// Dedicated procedure for hetero-agent ingest/finish endpoints.
// Requires a `hetero-operation` JWT (4h expiry) — normal user tokens are rejected,
// so only the sandbox/device that received the JWT from execAgent can call these.
const heteroAgentProcedure = heteroAuthedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
return opts.next({
ctx: {
heterogeneousAgentService: new HeterogeneousAgentService(ctx.serverDB, ctx.userId),
},
});
});
export const aiAgentRouter = router({
/**
* Create Thread for client-side task execution in Group mode
@@ -1239,7 +1220,7 @@ export const aiAgentRouter = router({
* existing stream fanout so renderer-side gateway WS subscribers see them
* unchanged. Phase 2a: pub/sub only no DB persistence (phase 2b adds it).
*/
heteroIngest: heteroAgentProcedure.input(HeteroIngestSchema).mutation(async ({ input, ctx }) => {
heteroIngest: aiAgentProcedure.input(HeteroIngestSchema).mutation(async ({ input, ctx }) => {
const { agentType, events, operationId, topicId } = input;
log(
@@ -1277,7 +1258,7 @@ export const aiAgentRouter = router({
* `agent_runtime_end` so renderer subscribers can shut down even when the
* CLI's own end-event was lost mid-flight.
*/
heteroFinish: heteroAgentProcedure.input(HeteroFinishSchema).mutation(async ({ input, ctx }) => {
heteroFinish: aiAgentProcedure.input(HeteroFinishSchema).mutation(async ({ input, ctx }) => {
const { agentType, error, operationId, result, sessionId, topicId } = input;
log('heteroFinish: topic=%s op=%s type=%s result=%s', topicId, operationId, agentType, result);
-1
View File
@@ -626,7 +626,6 @@ export const topicRouter = router({
})
.nullable()
.optional(),
repos: z.array(z.string()).optional(),
workingDirectory: z.string().optional(),
}),
}),
+4 -45
View File
@@ -53,7 +53,7 @@ import { UserModel } from '@/database/models/user';
import { UserPersonaModel } from '@/database/models/userMemory/persona';
import { toolsEnv } from '@/envs/tools';
import { shouldEnableBuiltinSkill } from '@/helpers/skillFilters';
import { signOperationJwt, signUserJWT } from '@/libs/trpc/utils/internalJwt';
import { signUserJWT } from '@/libs/trpc/utils/internalJwt';
import type { EvalContext, ServerAgentToolsContext } from '@/server/modules/Mecha';
import { createServerAgentToolsEngine } from '@/server/modules/Mecha';
import type { ServerUserMemoryConfig } from '@/server/modules/Mecha/ContextEngineering/types';
@@ -588,20 +588,14 @@ export class AiAgentService {
throw new Error('Resume mode requires the parent message to belong to a topic');
}
// Prepare metadata with cronJobId, taskId, botContext, bound device, and any
// client-supplied initial metadata (e.g. repos selected before first message).
const initialTopicMeta = appContext?.initialTopicMetadata;
// Prepare metadata with cronJobId, taskId, botContext, and bound device if provided
const metadata =
cronJobId || operationTaskId || botContext || requestedDeviceId || initialTopicMeta
cronJobId || operationTaskId || botContext || requestedDeviceId
? {
bot: botContext,
boundDeviceId: requestedDeviceId,
cronJobId: cronJobId || undefined,
taskId: operationTaskId,
...(initialTopicMeta?.repos && { repos: initialTopicMeta.repos }),
...(initialTopicMeta?.workingDirectory && {
workingDirectory: initialTopicMeta.workingDirectory,
}),
}
: undefined;
@@ -674,53 +668,18 @@ export class AiAgentService {
// heteroIngest / heteroFinish without full user credentials.
let operationJwt: string;
try {
operationJwt = await signOperationJwt(this.userId);
operationJwt = await signUserJWT(this.userId);
} catch (err) {
log('execAgent: failed to sign operation JWT for hetero run: %O', err);
throw new Error('Failed to sign operation JWT for hetero agent', { cause: err });
}
// Read repos from topic metadata for sandbox setup (web/cloud only).
const topic = await this.topicModel.findById(topicId);
const topicRepos: string[] = topic?.metadata?.repos ?? [];
// Resolve GitHub OAuth token so the sandbox can clone private repos.
let githubToken: string | undefined;
if (topicRepos.length > 0) {
const githubCredKey = agentConfig.agencyConfig?.heterogeneousProvider?.env?.GITHUB_CRED_KEY;
if (githubCredKey) {
try {
const list = await this.marketService.market.creds.list();
const cred = list.data?.find((c: { key: string }) => c.key === githubCredKey);
if (cred) {
const full = await this.marketService.market.creds.get(cred.id, { decrypt: true });
const vals = (full as any).plaintext ?? (full as any).values ?? {};
githubToken = vals.access_token ?? vals.token;
}
} catch (err) {
log('execAgent: failed to resolve GitHub token for repo clone: %O', err);
}
}
}
// Build cloud-specific system context (repo list + workspace info + optional agent-level static context).
const { buildCloudHeteroContext } =
await import('@/server/services/heterogeneousAgent/cloudHeteroContext');
const systemContext = buildCloudHeteroContext({
agentSystemContext: agentConfig.agencyConfig?.heterogeneousProvider?.systemContext,
githubToken,
repos: topicRepos,
});
const heteroParams = {
agentType: heteroType,
githubToken,
jwt: operationJwt,
operationId,
prompt,
repos: topicRepos,
resumeSessionId,
systemContext,
topicId,
userId: this.userId,
};
@@ -349,25 +349,9 @@ export class HeterogeneousPersistenceHandler {
if (plugin.toolCallId) toolMsgIdByCallId.set(plugin.toolCallId, plugin.id);
}
// Restore in-progress accumulators and tool state from the current assistant
// message so a cold replica (Vercel serverless — each request is a new process)
// continues from where the previous request left off rather than overwriting
// with an empty/shorter value. Without this, every ingest call would reset
// accumulatedContent to '' and toolState.payloads to [], causing:
// - content truncation: warm instance writes "hello world", cold instance
// accumulates only " more text" and overwrites with that shorter string.
// - tool duplication: cold instance sees persistedIds={}, re-creates already-
// persisted tool messages, and overwrites assistant.tools[] with only the
// current batch's tools (losing all previous ones).
const currentMsg = await this.deps.messageModel.findById(currentAssistantMessageId);
const restoredContent = (currentMsg?.content ?? '') as string;
const restoredReasoning = (currentMsg?.reasoning as { content?: string } | null)?.content ?? '';
const restoredTools = (currentMsg?.tools ?? []) as ChatToolPayload[];
const restoredPersistedIds = new Set(restoredTools.map((t) => t.id));
state = {
accumulatedContent: restoredContent,
accumulatedReasoning: restoredReasoning,
accumulatedContent: '',
accumulatedReasoning: '',
agentId: topic.agentId ?? null,
currentAssistantMessageId,
lastModel: undefined,
@@ -376,18 +360,16 @@ export class HeterogeneousPersistenceHandler {
processedKeys: new Set(),
subagentRuns: new Map(),
toolMsgIdByCallId,
toolState: { payloads: restoredTools, persistedIds: restoredPersistedIds },
toolState: { payloads: [], persistedIds: new Set() },
topicId,
};
operationStates.set(operationId, state);
log(
'created state for operation %s on topic %s msgId=%s tools=%d restored(content=%d tools=%d)',
'created state for operation %s on topic %s msgId=%s tools=%d',
operationId,
topicId,
currentAssistantMessageId,
toolMsgIdByCallId.size,
restoredContent.length,
restoredTools.length,
);
return state;
}
@@ -121,7 +121,6 @@ const createHarness = (
return { success: true };
},
),
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
listMessagePluginsByTopic: vi.fn(async (_topicId: string) => []),
};
@@ -181,7 +181,6 @@ const createHarness = () => {
});
return { success: true };
}),
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
listMessagePluginsByTopic: vi.fn(async (_topicId: string) => []),
};
@@ -101,7 +101,6 @@ const createHarness = (params: {
return { success: true };
},
),
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
listMessagePluginsByTopic: vi.fn(async (_topicId: string) => []),
};
@@ -716,112 +715,4 @@ describe('HeterogeneousPersistenceHandler', () => {
).resolves.not.toThrow();
});
});
describe('cold replica state restoration (Vercel serverless)', () => {
it('restores accumulatedContent from DB so a cold instance does not truncate previous text', async () => {
const h = createHarness({
assistantMessageId: 'asst-1',
operationId: 'op-1',
topicId: 'topic-1',
});
// Batch 1 (warm instance): stream two text chunks, flush happens via flushBatchContent
await h.handler.ingest({
events: [
buildEvent('stream_chunk', 0, { chunkType: 'text', content: 'hello ' }, 1000),
buildEvent('stream_chunk', 0, { chunkType: 'text', content: 'world' }, 1001),
],
operationId: 'op-1',
topicId: 'topic-1',
});
// DB should have the partial content written by flushBatchContent
expect(h.messages.get('asst-1')?.content).toBe('hello world');
// Simulate cold replica: drop the in-memory operation state
__resetOperationStatesForTesting();
// Batch 2 (cold instance): receives more text.
// Without restoration the new instance would start with accumulatedContent='' and
// write only " more" — truncating "hello world".
await h.handler.ingest({
events: [buildEvent('agent_runtime_end', 0, { reason: 'success' }, 2000)],
operationId: 'op-1',
topicId: 'topic-1',
});
// The terminal flush should preserve the previously accumulated content.
expect(h.messages.get('asst-1')?.content).toBe('hello world');
});
it('restores toolState.payloads and persistedIds so cold replica does not duplicate tools or overwrite tools[]', async () => {
const h = createHarness({
assistantMessageId: 'asst-1',
operationId: 'op-1',
topicId: 'topic-1',
});
// Batch 1 (warm): persist tool1
const tool1: any = {
apiName: 'tool1',
arguments: '{}',
id: 'tc-1',
identifier: 'tool1',
type: 'default',
};
await h.handler.ingest({
events: [
buildEvent(
'stream_chunk',
0,
{ chunkType: 'tools_calling', toolsCalling: [tool1] },
1000,
),
],
operationId: 'op-1',
topicId: 'topic-1',
});
// assistant.tools[] should have tool1
const asstAfterBatch1 = h.messages.get('asst-1')!;
expect(asstAfterBatch1.tools).toHaveLength(1);
expect(asstAfterBatch1.tools![0].id).toBe('tc-1');
// tool message created for tool1
const toolMsgsBatch1 = [...h.messages.values()].filter((m) => m.role === 'tool');
expect(toolMsgsBatch1).toHaveLength(1);
// Simulate cold replica: drop in-memory state
__resetOperationStatesForTesting();
// Batch 2 (cold): receives tool2 — should ADD to tools[], not overwrite
const tool2: any = {
apiName: 'tool2',
arguments: '{}',
id: 'tc-2',
identifier: 'tool2',
type: 'default',
};
await h.handler.ingest({
events: [
buildEvent(
'stream_chunk',
1,
{ chunkType: 'tools_calling', toolsCalling: [tool2] },
2000,
),
],
operationId: 'op-1',
topicId: 'topic-1',
});
const asstAfterBatch2 = h.messages.get('asst-1')!;
// Both tools should be present — cold restore kept tool1 in payloads
expect(asstAfterBatch2.tools).toHaveLength(2);
// tool1 should NOT be duplicated — persistedIds was restored
const allToolMsgs = [...h.messages.values()].filter((m) => m.role === 'tool');
const tool1Msgs = allToolMsgs.filter((m) => m.tool_call_id === 'tc-1');
expect(tool1Msgs).toHaveLength(1);
});
});
});
@@ -32,7 +32,6 @@ describe('HeterogeneousAgentService — phase 2c session id persistence + resume
// Real handler so we exercise the persistSessionId path end-to-end
const handler = new HeterogeneousPersistenceHandler({
messageModel: {
findById: vi.fn(async () => null),
listMessagePluginsByTopic: vi.fn(async () => []),
update: vi.fn(async () => ({ success: true })),
} as any,
@@ -88,7 +87,6 @@ describe('HeterogeneousAgentService — phase 2c session id persistence + resume
const handler = new HeterogeneousPersistenceHandler({
messageModel: {
findById: vi.fn(async () => null),
listMessagePluginsByTopic: vi.fn(async () => []),
update: vi.fn(async () => ({ success: true })),
} as any,
@@ -140,7 +138,6 @@ describe('HeterogeneousAgentService — phase 2c session id persistence + resume
const handler = new HeterogeneousPersistenceHandler({
messageModel: {
findById: vi.fn(async () => null),
listMessagePluginsByTopic: vi.fn(async () => []),
update: vi.fn(async () => ({ success: true })),
} as any,
@@ -199,7 +196,6 @@ describe('HeterogeneousAgentService — phase 2c session id persistence + resume
const handler = new HeterogeneousPersistenceHandler({
messageModel: {
findById: vi.fn(async () => null),
listMessagePluginsByTopic: vi.fn(async () => []),
update: vi.fn(async () => ({ success: true })),
} as any,
@@ -261,7 +257,6 @@ describe('HeterogeneousAgentService — phase 2c session id persistence + resume
const handler = new HeterogeneousPersistenceHandler({
messageModel: {
findById: vi.fn(async () => null),
listMessagePluginsByTopic: vi.fn(async () => []),
update: vi.fn(async () => ({ success: true })),
} as any,
@@ -1,92 +0,0 @@
/**
* Builds the system context injected before every user prompt for cloud Claude Code runs.
*
* This context is cloud-sandbox-specific: it describes the workspace layout,
* lists the GitHub repos that were pre-cloned, and tells CC how to handle
* repos that may not have been cloned successfully.
*
* It is NOT the agent's systemRole (which lives in agentConfig.systemRole and
* is a user-facing persona definition). This is pure infra context for CC.
*
* Returned string is passed as the first text block in the --input-json array
* via sandboxRunner spawnHeteroSandbox. If nothing meaningful to inject,
* returns undefined so no extra block is added.
*/
export function buildCloudHeteroContext(params: {
repos: string[];
/** Static systemContext from HeterogeneousProviderConfig.systemContext (agent-level). */
agentSystemContext?: string;
/** GitHub OAuth token injected as GITHUB_TOKEN env var in the sandbox. */
githubToken?: string;
}): string {
const { repos, agentSystemContext, githubToken } = params;
const parts: string[] = [];
// --- Agent-level static context (highest priority, goes first) ---
if (agentSystemContext?.trim()) {
parts.push(agentSystemContext.trim());
}
// --- Cloud workspace context ---
const workspaceLines: string[] = [
'## Cloud Workspace',
'You are running inside a LobeHub cloud sandbox. Your working directory is `/workspace`.',
];
if (githubToken) {
workspaceLines.push(
'',
'## GitHub Authentication',
'A GitHub OAuth token is available as the `GITHUB_TOKEN` environment variable.',
'Use it for:',
'- Authenticated git operations (`git clone`, `git push`, `git pull`, etc.)',
'- GitHub CLI: `gh` commands work out of the box',
'- GitHub API calls: pass it as `Authorization: Bearer $GITHUB_TOKEN`',
'- Accessing private repositories',
);
}
if (repos.length > 0) {
workspaceLines.push(
'',
'## GitHub Repositories',
'The following repositories were pre-cloned into `/workspace` before this conversation started:',
...repos.map((repo) => {
const dir = repoToLocalDir(repo);
const url = toGithubUrl(repo);
return `- \`/workspace/${dir}\` (${url})`;
}),
'',
'You can start working in any of these directories immediately.',
githubToken
? 'If a directory is missing (clone may have failed), you can recover it yourself using the available GITHUB_TOKEN.'
: 'If a directory is missing (clone may have failed), you can run `git clone <url> /workspace/<dir>` yourself to recover it.',
);
} else {
workspaceLines.push(
'',
'No GitHub repositories have been pre-cloned for this conversation.',
githubToken
? 'If you need a repository, you can clone it yourself using the available GITHUB_TOKEN.'
: 'If you need a repository, ask the user to add it in the repo selector, or clone it yourself with `git clone <url> /workspace/<dir>`.',
);
}
parts.push(workspaceLines.join('\n'));
return parts.join('\n\n');
}
// ---------------------------------------------------------------------------
// Helpers (mirrors sandboxRunner logic — kept local to avoid coupling)
// ---------------------------------------------------------------------------
function repoToLocalDir(repo: string): string {
return (repo.split('/').findLast(Boolean) ?? repo).replace(/\.git$/, '');
}
function toGithubUrl(repo: string): string {
if (repo.startsWith('http')) return repo.replace(/\.git$/, '');
return `https://github.com/${repo}`;
}
@@ -8,61 +8,16 @@ const log = debug('lobe-server:hetero-sandbox-runner');
export interface SandboxRunParams {
agentType: 'claude-code' | 'codex';
cwd?: string;
/** GitHub OAuth token for cloning private repos. */
githubToken?: string;
/** Operation-scoped JWT injected as LOBEHUB_JWT env in the sandbox. */
jwt: string;
marketService: MarketService;
operationId: string;
prompt: string;
/** GitHub repos to clone before running the agent (e.g. ['owner/repo', ...]). */
repos?: string[];
resumeSessionId?: string;
/**
* Optional context injected as a text block BEFORE the user's prompt.
* Useful for priming CC with workspace state (cloned repos, env info, etc.).
* Passed via --input-json as a JSON content-block array lh already supports this.
*/
systemContext?: string;
topicId: string;
userId: string;
}
/**
* Derive the local directory name from a repo identifier.
* Accepts "owner/repo", "https://github.com/owner/repo", or "https://github.com/owner/repo.git".
* Only allows safe characters to prevent shell injection.
*/
function repoToLocalDir(repo: string): string {
const raw = (repo.split('/').findLast(Boolean) ?? repo).replace(/\.git$/, '');
return raw.replaceAll(/[^\w.-]/g, '');
}
/**
* Build an idempotent setup script that clones each repo if not already present.
* Uses `[ -d <dir> ] || git clone ...` so re-runs on the same sandbox are no-ops.
* Returns null when repos is empty.
*/
function buildRepoSetupScript(repos: string[], githubToken?: string): string | null {
if (!repos || repos.length === 0) return null;
const lines = repos.map((repo) => {
const dir = repoToLocalDir(repo);
// Normalise to "owner/repo" for the clone URL
const repoPath = repo.startsWith('http') ? (repo.split('github.com/')[1] ?? repo) : repo;
// Use git's insteadOf rewrite (passed via -c, not stored in .git/config) so the token
// never ends up in the cloned repo's remote URL.
const cloneCmd = githubToken
? `git -c "url.https://oauth2:${githubToken}@github.com/.insteadOf=https://github.com/" clone -q https://github.com/${repoPath} '${dir}'`
: `git clone -q 'https://github.com/${repoPath}' '${dir}'`;
// `|| true` makes clone failures non-fatal — CC still runs even if a repo can't be cloned.
return `{ [ -d '${dir}' ] || ${cloneCmd}; } || true`;
});
return lines.join(' && \\\n');
}
/**
* Launches `lh hetero exec` inside the cloud sandbox via `runCommand`.
*
@@ -79,23 +34,16 @@ function buildRepoSetupScript(repos: string[], githubToken?: string): string | n
export async function spawnHeteroSandbox(params: SandboxRunParams): Promise<void> {
const {
agentType,
githubToken,
cwd,
jwt,
marketService,
operationId,
prompt,
repos,
resumeSessionId,
topicId,
userId,
} = params;
// For cloud sandbox, default cwd is /workspace — must be explicit so CC stores and
// finds session files at the same path on every invocation (session files live under
// ~/.claude/projects/<encoded-cwd>/). Without a consistent --cwd the session id stored
// in topic.metadata.heteroSessionId can't be resolved on --resume after a page reload.
const cwd = params.cwd ?? '/workspace';
// Build the `lh hetero exec` command string.
// Prompt is passed via --input-json stdin ('-') to avoid shell quoting issues
// with arbitrary user text in --prompt.
@@ -117,36 +65,20 @@ export async function spawnHeteroSandbox(params: SandboxRunParams): Promise<void
if (resumeSessionId) {
args.push('--resume', resumeSessionId);
}
args.push('--cwd', cwd);
if (cwd) {
args.push('--cwd', cwd);
}
// Encode the prompt as base64 to avoid all shell quoting issues.
// echo + shell quoting mangled inner JSON quotes; base64 is quote-safe.
// When systemContext is provided, send a content-block array so CC sees the
// context block first, then the user's actual message. lh already handles
// JSON arrays via coerceJsonPrompt — no lh changes required.
const { systemContext } = params;
const stdinPayload = systemContext
? JSON.stringify([
{ text: systemContext, type: 'text' },
{ text: prompt, type: 'text' },
])
: JSON.stringify(prompt);
const stdinPayload = JSON.stringify(prompt);
const base64Payload = Buffer.from(stdinPayload).toString('base64');
// LOBEHUB_HETERO_SERVER_URL overrides the server URL for local dev/testing
// (e.g. a cloudflare tunnel). APP_URL is NOT used here because it's tied to
// auth callbacks and must stay as localhost in dev.
const serverUrl = process.env.LOBEHUB_HETERO_SERVER_URL ?? appEnv.APP_URL;
const envVars = [
`LOBEHUB_JWT=${JSON.stringify(jwt)}`,
`LOBEHUB_SERVER=${JSON.stringify(serverUrl)}`,
// Inject GitHub token so CC can authenticate git operations and GitHub API
// calls inside the sandbox (e.g. gh CLI, git push, API requests).
...(githubToken ? [`GITHUB_TOKEN=${JSON.stringify(githubToken)}`] : []),
].join(' ');
const mainCommand = `echo ${base64Payload} | base64 -d | ${envVars} ${args.join(' ')}`;
const setupScript = buildRepoSetupScript(repos ?? [], githubToken);
const shellCommand = setupScript ? `${setupScript} && \\\n${mainCommand}` : mainCommand;
const shellCommand = `echo ${base64Payload} | base64 -d | LOBEHUB_JWT=${JSON.stringify(jwt)} LOBEHUB_SERVER=${JSON.stringify(serverUrl)} ${args.join(' ')}`;
log(
'spawnHeteroSandbox: userId=%s op=%s type=%s topic=%s',
-68
View File
@@ -1,68 +0,0 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KlavisService } from './index';
const mocks = vi.hoisted(() => ({
PluginModel: vi.fn(),
pluginQuery: vi.fn(),
}));
vi.mock('@/database/models/plugin', () => ({
PluginModel: mocks.PluginModel,
}));
vi.mock('@/libs/klavis', () => ({
getKlavisClient: vi.fn(),
isKlavisClientAvailable: vi.fn(() => true),
}));
vi.mock('debug', () => ({
default: vi.fn(() => vi.fn()),
}));
describe('KlavisService', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.PluginModel.mockImplementation(() => ({
query: mocks.pluginQuery,
}));
});
describe('getKlavisManifests', () => {
it('filters deprecated Klavis providers from server manifests', async () => {
mocks.pluginQuery.mockResolvedValue([
{
customParams: { klavis: { isAuthenticated: true, serverName: 'Gmail' } },
identifier: 'gmail',
manifest: {
api: [{ name: 'sendEmail', parameters: { type: 'object' } }],
meta: { title: 'Gmail' },
},
},
{
customParams: { klavis: { isAuthenticated: true, serverName: 'Notion' } },
identifier: 'notion',
manifest: {
api: [{ name: 'notion-search', parameters: { type: 'object' } }],
meta: { title: 'Notion' },
},
},
{
customParams: { klavis: { isAuthenticated: false, serverName: 'Google Calendar' } },
identifier: 'google-calendar',
manifest: {
api: [{ name: 'listEvents', parameters: { type: 'object' } }],
meta: { title: 'Google Calendar' },
},
},
]);
const service = new KlavisService({ db: {} as any, userId: 'user-1' });
const manifests = await service.getKlavisManifests();
expect(manifests.map((manifest) => manifest.identifier)).toEqual(['gmail']);
});
});
});
+4 -9
View File
@@ -1,6 +1,5 @@
import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
import type { LobeToolManifest } from '@lobechat/context-engine';
import type { LobeChatDatabase } from '@lobechat/database';
import { type LobeToolManifest } from '@lobechat/context-engine';
import { type LobeChatDatabase } from '@lobechat/database';
import debug from 'debug';
import { PluginModel } from '@/database/models/plugin';
@@ -9,8 +8,6 @@ import { type ToolExecutionResult } from '@/server/services/toolExecution/types'
const log = debug('lobe-server:klavis-service');
const VALID_KLAVIS_IDENTIFIERS = new Set(KLAVIS_SERVER_TYPES.map((type) => type.identifier));
export interface KlavisToolExecuteParams {
args: Record<string, any>;
/** Tool identifier (same as Klavis server identifier, e.g., 'google-calendar') */
@@ -191,11 +188,9 @@ export class KlavisService {
// Get all plugins from database
const allPlugins = await this.pluginModel.query();
// Filter plugins that have klavis customParams, are still supported, and are authenticated.
// Filter plugins that have klavis customParams and are authenticated
const klavisPlugins = allPlugins.filter(
(plugin) =>
VALID_KLAVIS_IDENTIFIERS.has(plugin.identifier) &&
plugin.customParams?.klavis?.isAuthenticated === true,
(plugin) => plugin.customParams?.klavis?.isAuthenticated === true,
);
log('getKlavisManifests: found %d authenticated Klavis plugins', klavisPlugins.length);
-19
View File
@@ -395,25 +395,6 @@ describe('MarketService', () => {
expect(manifests[0].identifier).toBe('linear');
});
it('should use the static Notion provider label for manifests', async () => {
const service = new MarketService();
(service as any).market.connect.listConnections = vi.fn().mockResolvedValue({
connections: [
{ icon: 'notion-icon', providerId: 'notion', providerName: 'User Workspace' },
],
});
(service as any).market.skills.listTools = vi.fn().mockResolvedValue({
tools: [{ description: 'Search workspace', inputSchema: {}, name: 'notion-search' }],
});
const manifests = await service.getLobehubSkillManifests();
expect(manifests).toHaveLength(1);
expect(manifests[0].meta).toMatchObject({
description: 'LobeHub Skill: Notion',
title: 'Notion',
});
});
it('should skip connections where listTools returns empty', async () => {
const service = new MarketService();
(service as any).market.connect.listConnections = vi.fn().mockResolvedValue({
-1
View File
@@ -542,7 +542,6 @@ export class MarketService {
github: 'GitHub',
linear: 'Linear',
microsoft: 'Outlook Calendar',
notion: 'Notion',
twitter: 'X (Twitter)',
vercel: 'Vercel',
};
@@ -154,20 +154,20 @@ describe('isTemplateSkillSourceEligible', () => {
});
it('keeps templates whose only source is enabled', () => {
const t = makeTemplate({ requiresSkills: [{ provider: 'notion', source: 'lobehub' }] });
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(true);
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: 'lobehub' }] });
expect(isTemplateSkillSourceEligible(t, new Set(['klavis']))).toBe(false);
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: 'notion', source: 'lobehub' },
{ provider: 'google-calendar', source: 'klavis' },
{ provider: 'github', source: 'lobehub' },
{ provider: 'notion', source: 'klavis' },
],
});
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(false);
+21 -21
View File
@@ -13,7 +13,6 @@ import ImagePage from '@/routes/(main)/(create)/image';
import DesktopImageLayout from '@/routes/(main)/(create)/image/_layout';
import VideoPage from '@/routes/(main)/(create)/video';
import DesktopVideoLayout from '@/routes/(main)/(create)/video/_layout';
import TaskWorkspaceLayout from '@/routes/(main)/(task-workspace)/_layout';
// Pages — sync import
import AgentPage from '@/routes/(main)/agent';
import DesktopChatLayout from '@/routes/(main)/agent/_layout';
@@ -77,8 +76,10 @@ import ResourceLibrarySlugPage from '@/routes/(main)/resource/library/[slug]';
import SettingsTabPage from '@/routes/(main)/settings';
import SettingsLayout from '@/routes/(main)/settings/_layout';
import { ProviderDetailPage, ProviderLayout } from '@/routes/(main)/settings/provider';
import TaskDetailLayout from '@/routes/(main)/task/_layout';
import TaskDetailRoute from '@/routes/(main)/task/[taskId]';
import AllTasksPage from '@/routes/(main)/tasks';
import AllTasksLayout from '@/routes/(main)/tasks/_layout';
import ShareTopicPage from '@/routes/share/t/[id]';
import ShareTopicLayout from '@/routes/share/t/[id]/_layout';
import { ErrorBoundary, redirectElement } from '@/utils/router';
@@ -458,31 +459,30 @@ export const desktopRoutes: RouteObject[] = [
path: 'eval',
},
// Task workspace routes (cross-agent)
// Tasks routes (cross-agent)
{
children: [
{
children: [
{
element: <AllTasksPage />,
index: true,
},
],
errorElement: <ErrorBoundary resetPath="/" />,
path: 'tasks',
},
{
children: [
{
element: <TaskDetailRoute />,
path: ':taskId',
},
],
errorElement: <ErrorBoundary resetPath="/tasks" />,
path: 'task',
element: <AllTasksPage />,
index: true,
},
],
element: <TaskWorkspaceLayout />,
element: <AllTasksLayout />,
errorElement: <ErrorBoundary resetPath="/" />,
path: 'tasks',
},
// Task detail route (cross-agent entry — resolves by task identifier)
{
children: [
{
element: <TaskDetailRoute />,
path: ':taskId',
},
],
element: <TaskDetailLayout />,
errorElement: <ErrorBoundary resetPath="/tasks" />,
path: 'task',
},
// Pages routes
+26 -24
View File
@@ -568,37 +568,39 @@ export const desktopRoutes: RouteObject[] = [
path: 'eval',
},
// Task workspace routes (cross-agent)
// Tasks routes (cross-agent)
{
children: [
{
children: [
{
element: dynamicElement(() => import('@/routes/(main)/tasks'), 'Desktop > Tasks'),
index: true,
},
],
errorElement: <ErrorBoundary resetPath="/" />,
path: 'tasks',
},
{
children: [
{
element: dynamicElement(
() => import('@/routes/(main)/task/[taskId]'),
'Desktop > Task Detail',
),
path: ':taskId',
},
],
errorElement: <ErrorBoundary resetPath="/tasks" />,
path: 'task',
element: dynamicElement(() => import('@/routes/(main)/tasks'), 'Desktop > Tasks'),
index: true,
},
],
element: dynamicLayout(
() => import('@/routes/(main)/(task-workspace)/_layout'),
'Desktop > Task Workspace > Layout',
() => import('@/routes/(main)/tasks/_layout'),
'Desktop > Tasks > Layout',
),
errorElement: <ErrorBoundary resetPath="/" />,
path: 'tasks',
},
// Task detail route (cross-agent entry — resolves by task identifier)
{
children: [
{
element: dynamicElement(
() => import('@/routes/(main)/task/[taskId]'),
'Desktop > Task Detail',
),
path: ':taskId',
},
],
element: dynamicLayout(
() => import('@/routes/(main)/task/_layout'),
'Desktop > Task > Layout',
),
errorElement: <ErrorBoundary resetPath="/tasks" />,
path: 'task',
},
// Pages routes
+8 -21
View File
@@ -23,16 +23,16 @@ function normalizePaths(paths: string[]) {
return [...new Set(paths.map((path) => KNOWN_DIVERGENCES[path] ?? path))].sort();
}
async function readDesktopRouterSources() {
return Promise.all([
readFile(join(process.cwd(), 'src/spa/router/desktopRouter.config.tsx'), 'utf8'),
readFile(join(process.cwd(), 'src/spa/router/desktopRouter.config.desktop.tsx'), 'utf8'),
]);
}
describe('desktopRouter config sync', () => {
it('desktop (sync) route paths must match web (async) route paths', async () => {
const [asyncSource, syncSource] = await readDesktopRouterSources();
const asyncSource = await readFile(
join(process.cwd(), 'src/spa/router/desktopRouter.config.tsx'),
'utf8',
);
const syncSource = await readFile(
join(process.cwd(), 'src/spa/router/desktopRouter.config.desktop.tsx'),
'utf8',
);
const asyncPaths = normalizePaths(extractPaths(asyncSource));
const syncPaths = normalizePaths(extractPaths(syncSource));
@@ -48,17 +48,4 @@ describe('desktopRouter config sync', () => {
asyncIndexCount,
);
});
it('task list and detail desktop routes share one workspace layout', async () => {
const [asyncSource, syncSource] = await readDesktopRouterSources();
expect(asyncSource).toContain("import('@/routes/(main)/(task-workspace)/_layout')");
expect(syncSource).toContain("from '@/routes/(main)/(task-workspace)/_layout'");
expect(asyncSource).not.toContain("import('@/routes/(main)/task-workspace/_layout')");
expect(syncSource).not.toContain("from '@/routes/(main)/task-workspace/_layout'");
expect(asyncSource).not.toContain("import('@/routes/(main)/tasks/_layout')");
expect(asyncSource).not.toContain("import('@/routes/(main)/task/_layout')");
expect(syncSource).not.toContain("from '@/routes/(main)/tasks/_layout'");
expect(syncSource).not.toContain("from '@/routes/(main)/task/_layout'");
});
});
+7 -24
View File
@@ -225,37 +225,20 @@ export const mobileRoutes: RouteObject[] = [
path: 'settings',
},
// Task workspace routes (cross-agent)
// Tasks routes (cross-agent)
{
children: [
{
children: [
{
element: dynamicElement(() => import('@/routes/(main)/tasks'), 'Mobile > Tasks'),
index: true,
},
],
errorElement: <ErrorBoundary resetPath="/" />,
path: 'tasks',
},
{
children: [
{
element: dynamicElement(
() => import('@/routes/(main)/task/[taskId]'),
'Mobile > Task Detail',
),
path: ':taskId',
},
],
errorElement: <ErrorBoundary resetPath="/tasks" />,
path: 'task',
element: dynamicElement(() => import('@/routes/(main)/tasks'), 'Mobile > Tasks'),
index: true,
},
],
element: dynamicLayout(
() => import('@/routes/(main)/(task-workspace)/_layout'),
'Mobile > Task Workspace > Layout',
() => import('@/routes/(main)/tasks/_layout'),
'Mobile > Tasks > Layout',
),
errorElement: <ErrorBoundary resetPath="/" />,
path: 'tasks',
},
...BusinessMobileRoutesWithMainLayout,
-21
View File
@@ -1,21 +0,0 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('mobileRouter task routes', () => {
it('registers task list and detail routes under the shared workspace layout', async () => {
const source = await readFile(
join(process.cwd(), 'src/spa/router/mobileRouter.config.tsx'),
'utf8',
);
expect(source).toContain("import('@/routes/(main)/(task-workspace)/_layout')");
expect(source).toContain("import('@/routes/(main)/tasks')");
expect(source).toContain("import('@/routes/(main)/task/[taskId]')");
expect(source).toContain("path: 'tasks'");
expect(source).toContain("path: 'task'");
expect(source).toContain("path: ':taskId'");
expect(source).not.toContain("import('@/routes/(main)/tasks/_layout')");
});
});
+1 -20
View File
@@ -464,26 +464,7 @@ export class StreamingHandler {
},
}));
const resolved = this.callbacks.transformToolCalls(processedToolCalls);
// Silent-drop guard: the model emitted tool_calls but every name failed to
// resolve (e.g. missing `____` prefix the resolver couldn't recover from).
// Without this log the operation would finish as "completed without tool
// calls" even though the user's intent was lost. See LOBE-8696.
if (resolved.length < processedToolCalls.length) {
const resolvedKeys = new Set(resolved.map((t) => t.id));
const unresolved = processedToolCalls
.filter((t) => !resolvedKeys.has(t.id))
.map((t) => t.function.name);
log(
'[processFinalToolCalls] unresolved tool_call names messageId=%s, operationId=%s, names=%o',
this.context.messageId,
this.context.operationId,
unresolved,
);
}
this.tools = resolved;
this.tools = this.callbacks.transformToolCalls(processedToolCalls);
this.isFunctionCall = true;
}
+46 -54
View File
@@ -317,58 +317,6 @@ export const createAgentExecutors = (context: {
let finalUsage: ModelUsage | undefined;
let finalToolCalls: MessageToolCall[] | undefined;
// Expand dynamically activated tools (from lobe-activator activateTools API)
// and merge them into the agent config for this LLM call.
// Built before the StreamingHandler so we can bind the offered tool
// names into the transformToolCalls callback (LOBE-8696).
const activatedToolIds = runtimeContext?.stepContext?.activatedToolIds;
let resolvedAgentConfig = context.agentConfig;
if (activatedToolIds?.length && context.toolsEngine) {
const additional = context.toolsEngine.generateToolsDetailed({
context: { isExplicitActivation: true },
model: agentConfigData.model,
provider: agentConfigData.provider!,
skipDefaultTools: true,
toolIds: activatedToolIds,
});
if (additional.tools?.length) {
const mergedEnabledManifests = dedupeBy(
[...(context.agentConfig.enabledManifests || []), ...additional.enabledManifests],
(manifest) => manifest.identifier,
);
const mergedEnabledToolIds = [
...new Set([
...(context.agentConfig.enabledToolIds || []),
...additional.enabledToolIds,
]),
];
const mergedTools = dedupeBy(
[...(context.agentConfig.tools || []), ...additional.tools],
(tool) => tool.function.name,
);
resolvedAgentConfig = {
...context.agentConfig,
enabledManifests: mergedEnabledManifests,
enabledToolIds: mergedEnabledToolIds,
tools: mergedTools,
};
log(
`${stagePrefix} Injected %d activated tools: %o`,
activatedToolIds.length,
activatedToolIds,
);
}
}
// Names of tools actually sent to the LLM this turn. Passed to the
// resolver's missing-prefix fallback so a model can't reach tools that
// weren't enabled, and disabled duplicates can't shadow enabled calls.
const offeredToolNames = (resolvedAgentConfig.tools ?? []).map((tool) => tool.function.name);
// Create streaming handler with callbacks
const handler = new StreamingHandler(
{
@@ -456,14 +404,58 @@ export const createAgentExecutors = (context: {
url: file?.url,
alt: file?.filename || file?.id,
})),
transformToolCalls: (calls) =>
context.get().internal_transformToolCalls(calls, offeredToolNames),
transformToolCalls: context.get().internal_transformToolCalls,
toggleToolCallingStreaming: internal_toggleToolCallingStreaming,
},
);
const messages = llmPayload.messages.filter((message) => message.id !== assistantMessageId);
// Expand dynamically activated tools (from lobe-activator activateTools API)
// and merge them into the agent config for this LLM call
const activatedToolIds = runtimeContext?.stepContext?.activatedToolIds;
let resolvedAgentConfig = context.agentConfig;
if (activatedToolIds?.length && context.toolsEngine) {
const additional = context.toolsEngine.generateToolsDetailed({
context: { isExplicitActivation: true },
model: agentConfigData.model,
provider: agentConfigData.provider!,
skipDefaultTools: true,
toolIds: activatedToolIds,
});
if (additional.tools?.length) {
const mergedEnabledManifests = dedupeBy(
[...(context.agentConfig.enabledManifests || []), ...additional.enabledManifests],
(manifest) => manifest.identifier,
);
const mergedEnabledToolIds = [
...new Set([
...(context.agentConfig.enabledToolIds || []),
...additional.enabledToolIds,
]),
];
const mergedTools = dedupeBy(
[...(context.agentConfig.tools || []), ...additional.tools],
(tool) => tool.function.name,
);
resolvedAgentConfig = {
...context.agentConfig,
enabledManifests: mergedEnabledManifests,
enabledToolIds: mergedEnabledToolIds,
tools: mergedTools,
};
log(
`${stagePrefix} Injected %d activated tools: %o`,
activatedToolIds.length,
activatedToolIds,
);
}
}
await chatService.createAssistantMessageStream({
abortController,
params: {
-34
View File
@@ -1,34 +0,0 @@
/**
* Module-level singleton for pending repo selections.
*
* When a user selects GitHub repos before sending the first message (no topic
* exists yet), the selections are buffered here keyed by agentId. As soon as
* the server creates a topic for that agent, gateway.ts consumes these repos
* and writes them into the topic metadata immediately avoiding the race
* condition where the store action would drop the update because the topic
* object hadn't appeared in topicDataMap yet.
*
* Desktop builds: CloudRepoSwitcher is never rendered, so these functions are
* never called and the map stays empty.
*/
const map = new Map<string, string[]>();
/** Record pending repos for an agent (overwrites previous value). */
export const setPendingTopicRepos = (agentId: string, repos: string[]): void => {
if (repos.length === 0) map.delete(agentId);
else map.set(agentId, [...repos]);
};
/**
* Consume and return pending repos for an agent.
* Clears the entry so a second call returns [].
*/
export const consumePendingTopicRepos = (agentId: string): string[] => {
const repos = map.get(agentId);
map.delete(agentId);
return repos ?? [];
};
/** Read pending repos without consuming them (for display). */
export const getPendingTopicRepos = (agentId: string): string[] => map.get(agentId) ?? [];
@@ -16,13 +16,13 @@ describe('selectRuntimeType', () => {
expect(selectRuntimeType({ isGatewayMode: true }, opts)).toBe('gateway');
});
it('routes heterogeneousProvider to gateway on web — cloud sandbox is the only execution env', () => {
it('ignores heterogeneousProvider on web — falls through to gateway/client', () => {
expect(
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: true }, opts),
).toBe('gateway');
expect(
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: false }, opts),
).toBe('gateway');
).toBe('client');
});
});
@@ -46,9 +46,6 @@ export const selectRuntimeType = (
): AgentRuntimeType => {
if (ctx.parentRuntime) return ctx.parentRuntime;
if (isDesktop && ctx.heterogeneousProvider) return 'hetero';
// On web, heterogeneous agents always run via Gateway sandbox regardless of the
// isGatewayMode user preference — the sandbox is the only execution environment.
if (!isDesktop && ctx.heterogeneousProvider) return 'gateway';
if (ctx.isGatewayMode) return 'gateway';
return 'client';
};
@@ -10,7 +10,6 @@ import { isDesktop } from '@/const/version';
import { aiAgentService, type ResumeApprovalParam } from '@/services/aiAgent';
import { messageService } from '@/services/message';
import { topicService } from '@/services/topic';
import { consumePendingTopicRepos, getPendingTopicRepos } from '@/store/chat/pendingTopicRepos';
import type { ChatStore } from '@/store/chat/store';
import type { StoreSetter } from '@/store/types';
import { useUserStore } from '@/store/user';
@@ -292,17 +291,6 @@ export class GatewayActionImpl {
const isCreateNewTopic = !context.topicId;
const taskId = context.viewedTask?.type === 'detail' ? context.viewedTask.taskId : undefined;
// If this is a new topic, read any repos the user pre-selected before
// sending the first message. We read without consuming yet — if execAgentTask
// fails or is aborted, the selection is preserved so a retry can still pick
// it up. We clear only after the server confirms the topic was created.
const pendingRepos =
isCreateNewTopic && context.agentId ? getPendingTopicRepos(context.agentId) : [];
const initialTopicMetadata =
pendingRepos.length > 0
? { repos: pendingRepos, workingDirectory: pendingRepos[0] }
: undefined;
// Honour user-initiated cancel during phase-1 init: while we await the
// execAgentTask round-trip the caller's loading state (e.g. `sendMessage`)
// is still running, so the ChatInput stop button is active. Forward the
@@ -321,7 +309,6 @@ export class GatewayActionImpl {
defaultTaskAssigneeAgentId: context.defaultTaskAssigneeAgentId,
documentId: context.documentId,
groupId: context.groupId,
...(initialTopicMetadata && { initialTopicMetadata }),
scope: context.scope,
taskId,
threadId: context.threadId,
@@ -350,8 +337,6 @@ export class GatewayActionImpl {
// If server created a new topic, fetch messages first then switch topic
// (same pattern as client mode: replaceMessages before switchTopic to avoid skeleton flash)
if (isCreateNewTopic && result.topicId) {
// Topic created successfully — now safe to clear the pending repo selection.
if (context.agentId) consumePendingTopicRepos(context.agentId);
try {
const newContext = { ...context, topicId: result.topicId };
const messages = await messageService.getMessages(newContext);
@@ -365,14 +350,6 @@ export class GatewayActionImpl {
skipRefreshMessage: true,
});
// Refresh the topic list so the new topic appears in topicDataMap (sidebar).
// Unlike the direct-API sendMessage path (which receives topics[] in the
// response and calls internal_updateTopics), the gateway path only gets a
// topicId — we must explicitly refetch so the sidebar shows the new topic.
this.#get()
.refreshTopic()
.catch((err) => console.error('[Gateway] refreshTopic after topic creation failed:', err));
if (abortSignal?.aborted) {
aiAgentService
.interruptTask({ operationId: result.operationId })
@@ -467,17 +444,6 @@ export class GatewayActionImpl {
window.global_serverConfigStore?.getState()?.serverConfig?.agentGatewayUrl;
if (!agentGatewayUrl) return;
// Skip reconnect if the gateway action already established (or is establishing)
// a fresh connection for this operation. This prevents a race on new-topic creation
// where switchTopic loads runningOperation → useGatewayReconnect fires → overwrites
// the connectToGateway call made by executeGatewayAgent with resumeOnConnect: true,
// causing the gateway to treat a brand-new session as a resume → stuck / no events.
// Any status other than 'disconnected' means the gateway action already owns this
// connection (connecting / authenticating / reconnecting / connected). Skip to avoid
// overwriting the fresh non-resume connect with resumeOnConnect:true.
const existingStatus = this.#get().gatewayConnections[operationId]?.status;
if (existingStatus && existingStatus !== 'disconnected') return;
// Get a fresh JWT token (original expired after 5 min)
const { token } = await aiAgentService.refreshGatewayToken(topicId);
@@ -27,10 +27,7 @@ export class PluginInternalsActionImpl {
void get;
}
internal_transformToolCalls = (
toolCalls: MessageToolCall[],
offeredToolNames?: string[],
): ChatToolPayload[] => {
internal_transformToolCalls = (toolCalls: MessageToolCall[]): ChatToolPayload[] => {
const toolNameResolver = new ToolNameResolver();
// Build manifests map from tool store
@@ -76,7 +73,7 @@ export class PluginInternalsActionImpl {
}
// Resolve tool calls and add source field
const resolved = toolNameResolver.resolve(toolCalls, manifests, offeredToolNames);
const resolved = toolNameResolver.resolve(toolCalls, manifests);
return resolved.map((payload) => {
// Parse and repair arguments if needed
+5 -13
View File
@@ -76,22 +76,15 @@ const currentActiveTopicSummary = (s: ChatStoreState): ChatTopicSummary | undefi
};
};
const currentTopicMetadata = (s: ChatStoreState) => currentActiveTopic(s)?.metadata;
/**
* Get current active topic's working directory.
* On desktop: local filesystem path.
* On web (cloud): primary GitHub repo URL (repos[0]), or workingDirectory if set directly.
* Get current active topic's working directory
* Returns undefined if no topic is active or no working directory is set
*/
const currentTopicWorkingDirectory = (s: ChatStoreState): string | undefined => {
if (!isDesktop) return;
const activeTopic = currentActiveTopic(s);
if (!activeTopic) return;
if (isDesktop) return activeTopic.metadata?.workingDirectory;
// Web: return primary repo from repos list, or workingDirectory if set directly
const meta = activeTopic.metadata;
return meta?.repos?.[0] ?? meta?.workingDirectory;
return activeTopic?.metadata?.workingDirectory;
};
const isCreatingTopic = (s: ChatStoreState) => s.creatingTopic;
@@ -182,7 +175,6 @@ export const topicSelectors = {
currentTopicCount,
currentTopicData,
currentTopicLength,
currentTopicMetadata,
currentTopicWorkingDirectory,
currentTopics,
currentTopicsWithoutCron,
+7 -25
View File
@@ -1,21 +1,20 @@
'use client';
import { EDITOR_DEBOUNCE_TIME, EDITOR_MAX_WAIT } from '@lobechat/const';
import type { DocumentItem } from '@lobechat/database/schemas';
import type { IEditor } from '@lobehub/editor';
import { type DocumentItem } from '@lobechat/database/schemas';
import { type IEditor } from '@lobehub/editor';
import { debounce } from 'es-toolkit/compat';
import type { SWRResponse } from 'swr';
import { type SWRResponse } from 'swr';
import { useClientDataSWRWithSync } from '@/libs/swr';
import { documentService } from '@/services/document';
import { documentSWRKeys } from '@/services/document/swrKeys';
import { usePageStore } from '@/store/page';
import type { StoreSetter } from '@/store/types';
import { isSkillMarkdownDocument, parseSkillMarkdownFrontmatter } from '@/utils/skillMarkdown';
import { type StoreSetter } from '@/store/types';
import { setNamespace } from '@/utils/storeDebug';
import type { DocumentStore } from '../../store';
import type { DocumentContentFormat, DocumentSourceType } from '../editor/initialState';
import { type DocumentStore } from '../../store';
import { type DocumentSourceType } from '../editor/initialState';
const n = setNamespace('document/document');
@@ -29,7 +28,6 @@ export interface InitDocumentParams {
*/
autoSave?: boolean;
content?: string | null;
contentFormat?: DocumentContentFormat;
documentId: string;
editor: IEditor;
editorData?: unknown;
@@ -139,22 +137,9 @@ export class DocumentActionImpl {
* Content is loaded into editor via onEditorInit when Editor component is ready.
*/
initDocumentWithEditor = (params: InitDocumentParams): void => {
const {
autoSave,
content,
contentFormat,
documentId,
editor,
editorData,
sourceType,
topicId,
} = params;
const { autoSave, content, documentId, editor, editorData, sourceType, topicId } = params;
const { internal_dispatchDocument } = this.#get();
const skillFrontmatter =
contentFormat === 'skillMarkdown'
? parseSkillMarkdownFrontmatter(content).frontmatter
: undefined;
// Add or update document via reducer
internal_dispatchDocument({
@@ -163,13 +148,11 @@ export class DocumentActionImpl {
value: {
autoSave,
content: content ?? undefined,
contentFormat,
editorData,
lastSavedContent: content ?? undefined,
lastSavedEditorData: editorData,
sourceType,
skillFrontmatter,
topicId,
},
});
@@ -242,7 +225,6 @@ export class DocumentActionImpl {
this.#get().initDocumentWithEditor({
autoSave,
content: document.content,
contentFormat: isSkillMarkdownDocument(document) ? 'skillMarkdown' : 'markdown',
documentId,
editor,
editorData: document.editorData,
@@ -198,127 +198,6 @@ describe('DocumentStore - Editor Actions', () => {
expect(mockEditor.setDocument).toHaveBeenCalledWith('markdown', '# Hello World');
});
it('should load only the editable body for SKILL.md frontmatter documents', () => {
const { result } = renderHook(() => useDocumentStore());
const mockEditor = createMockEditor() as any;
const skillContent = `---
description: >-
Retrieves comments from YouTube videos.
name: youtube-comment-retrieval-workflow
---
# YouTube Comment Retrieval Workflow`;
act(() => {
result.current.initDocumentWithEditor({
content: skillContent,
contentFormat: 'skillMarkdown',
documentId: 'doc-1',
editor: mockEditor,
sourceType: 'notebook',
});
});
act(() => {
result.current.onEditorInit(mockEditor);
});
expect(mockEditor.setDocument).toHaveBeenCalledWith(
'markdown',
'# YouTube Comment Retrieval Workflow',
);
expect(result.current.documents['doc-1'].skillFrontmatter).toContain(
'youtube-comment-retrieval-workflow',
);
});
it('should preserve existing editorData for SKILL.md documents', () => {
const { result } = renderHook(() => useDocumentStore());
const mockEditor = createMockEditor() as any;
const editorData = {
root: {
children: [
{
children: [
{ children: [{ text: 'origin', type: 'text' }], type: 'paragraph' },
{ children: [{ text: 'modified', type: 'text' }], type: 'paragraph' },
],
diffType: 'modify',
type: 'diff',
},
],
type: 'root',
},
};
act(() => {
result.current.initDocumentWithEditor({
content: `---
description: Skill metadata
name: skill-name
---
# Body`,
contentFormat: 'skillMarkdown',
documentId: 'doc-1',
editor: mockEditor,
editorData,
sourceType: 'notebook',
});
});
act(() => {
result.current.onEditorInit(mockEditor);
});
expect(mockEditor.setDocument).toHaveBeenCalledTimes(1);
expect(mockEditor.setDocument).toHaveBeenCalledWith('json', JSON.stringify(editorData));
});
it('should fall back to editable body when SKILL.md editorData cannot be loaded', () => {
const { result } = renderHook(() => useDocumentStore());
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const editorData = {
root: { children: [{ children: [], type: 'paragraph' }], type: 'root' },
};
const mockEditor = {
getDocument: vi.fn(),
setDocument: vi.fn((type: string) => {
if (type === 'json') {
throw new Error('editorData unavailable');
}
}),
} as any;
act(() => {
result.current.initDocumentWithEditor({
content: `---
description: Skill metadata
name: skill-name
---
# Body`,
contentFormat: 'skillMarkdown',
documentId: 'doc-1',
editor: mockEditor,
editorData,
sourceType: 'notebook',
});
});
act(() => {
result.current.onEditorInit(mockEditor);
});
expect(consoleWarn).toHaveBeenCalledWith(
'[DocumentStore] Failed to load SKILL.md editorData, falling back to markdown',
);
expect(mockEditor.setDocument).toHaveBeenNthCalledWith(1, 'json', JSON.stringify(editorData));
expect(mockEditor.setDocument).toHaveBeenNthCalledWith(2, 'markdown', '# Body');
consoleWarn.mockRestore();
});
it('should load editorData as json into editor', () => {
const { result } = renderHook(() => useDocumentStore());
const mockEditor = createMockEditor() as any;
@@ -374,43 +253,6 @@ name: skill-name
JSON.stringify(EMPTY_EDITOR_STATE),
);
});
it('should fail safely when SKILL.md body cannot be loaded into the editor', async () => {
const { result } = renderHook(() => useDocumentStore());
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockEditor = {
getDocument: vi.fn(),
setDocument: vi.fn(() => {
throw new Error('editor unavailable');
}),
} as any;
act(() => {
result.current.initDocumentWithEditor({
content: `---
description: Skill metadata
name: skill-name
---
# Body`,
contentFormat: 'skillMarkdown',
documentId: 'doc-1',
editor: mockEditor,
sourceType: 'notebook',
});
});
await act(async () => {
await result.current.onEditorInit(mockEditor);
});
expect(consoleError).toHaveBeenCalledWith(
'[DocumentStore] Failed to load SKILL.md content:',
expect.any(Error),
);
consoleError.mockRestore();
});
});
describe('closeDocument', () => {
@@ -523,208 +365,6 @@ name: skill-name
isDirty: true,
});
});
it('should preserve SKILL.md frontmatter when syncing editor body changes', () => {
const { result } = renderHook(() => useDocumentStore());
const editorData = {
root: { children: [{ children: [], type: 'paragraph' }], type: 'root' },
};
const mockEditor = {
getDocument: vi.fn((type: string) => {
if (type === 'markdown') return '# Updated Skill';
if (type === 'json') return editorData;
return null;
}),
setDocument: vi.fn(),
} as any;
act(() => {
result.current.initDocumentWithEditor({
content: `---
description: Skill metadata
name: skill-name
---
# Original Skill`,
contentFormat: 'skillMarkdown',
documentId: 'doc-1',
editor: mockEditor,
editorData,
sourceType: 'notebook',
});
});
act(() => {
result.current.handleContentChange();
});
expect(result.current.documents['doc-1']).toMatchObject({
content: `---
description: Skill metadata
name: skill-name
---
# Updated Skill`,
isDirty: true,
});
});
it('should update SKILL.md frontmatter while preserving the editor body', () => {
const { result } = renderHook(() => useDocumentStore());
const editorData = {
root: { children: [{ children: [], type: 'paragraph' }], type: 'root' },
};
const mockEditor = {
getDocument: vi.fn((type: string) => {
if (type === 'markdown') return '# Current Body';
if (type === 'json') return editorData;
return null;
}),
setDocument: vi.fn(),
} as any;
act(() => {
result.current.initDocumentWithEditor({
content: `---
description: Old metadata
name: old-skill
---
# Original Body`,
contentFormat: 'skillMarkdown',
documentId: 'doc-1',
editor: mockEditor,
editorData,
sourceType: 'notebook',
});
});
act(() => {
result.current.updateSkillFrontmatter(
'doc-1',
`description: New metadata
name: new-skill`,
);
});
expect(result.current.documents['doc-1']).toMatchObject({
content: `---
description: New metadata
name: new-skill
---
# Current Body`,
isDirty: true,
skillFrontmatter: `description: New metadata
name: new-skill`,
});
});
it('should update an inactive SKILL.md document from stored content', () => {
const { result } = renderHook(() => useDocumentStore());
const mockEditor = createMockEditor() as any;
const editorData = {
root: { children: [{ children: [], type: 'paragraph' }], type: 'root' },
};
act(() => {
result.current.initDocumentWithEditor({
content: '# Active',
documentId: 'doc-active',
editor: mockEditor,
sourceType: 'notebook',
});
result.current.initDocumentWithEditor({
content: `---
description: Old metadata
name: old-skill
---
# Stored Body`,
contentFormat: 'skillMarkdown',
documentId: 'doc-inactive',
editor: mockEditor,
editorData,
sourceType: 'notebook',
});
result.current.initDocumentWithEditor({
content: '# Active',
documentId: 'doc-active',
editor: mockEditor,
sourceType: 'notebook',
});
});
act(() => {
result.current.updateSkillFrontmatter(
'doc-inactive',
`description: New metadata
name: new-skill`,
);
});
expect(result.current.documents['doc-inactive']).toMatchObject({
content: `---
description: New metadata
name: new-skill
---
# Stored Body`,
editorData,
isDirty: true,
});
});
it('should reject frontmatter updates for missing or non-SKILL documents', () => {
const { result } = renderHook(() => useDocumentStore());
const mockEditor = createMockEditor() as any;
act(() => {
result.current.initDocumentWithEditor({
content: '# Markdown',
documentId: 'doc-1',
editor: mockEditor,
sourceType: 'notebook',
});
});
expect(result.current.updateSkillFrontmatter('missing', 'name: skill')).toBe(false);
expect(result.current.updateSkillFrontmatter('doc-1', 'name: skill')).toBe(false);
});
it('should fail safely when active SKILL.md frontmatter update cannot read editor content', () => {
const { result } = renderHook(() => useDocumentStore());
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const mockEditor = {
getDocument: vi.fn(() => {
throw new Error('editor unavailable');
}),
setDocument: vi.fn(),
} as any;
act(() => {
result.current.initDocumentWithEditor({
content: `---
description: Skill metadata
name: skill-name
---
# Body`,
contentFormat: 'skillMarkdown',
documentId: 'doc-1',
editor: mockEditor,
sourceType: 'notebook',
});
});
expect(result.current.updateSkillFrontmatter('doc-1', 'name: skill-name')).toBe(false);
expect(consoleError).toHaveBeenCalledWith(
'[DocumentStore] Failed to update SKILL.md frontmatter:',
expect.any(Error),
);
consoleError.mockRestore();
});
});
describe('commitEditorMutation', () => {
@@ -1000,60 +640,5 @@ name: skill-name
);
expect(result.current.documents['doc-1'].isDirty).toBe(false);
});
it('should save SKILL.md with its original frontmatter restored', async () => {
const { result } = renderHook(() => useDocumentStore());
const editorData = {
root: { children: [{ children: [], type: 'paragraph' }], type: 'root' },
};
const mockEditor = {
getDocument: vi.fn((type: string) => {
if (type === 'markdown') return '# Updated Skill';
if (type === 'json') return editorData;
return null;
}),
setDocument: vi.fn(),
} as any;
act(() => {
result.current.initDocumentWithEditor({
content: `---
description: Skill metadata
name: skill-name
---
# Original Skill`,
contentFormat: 'skillMarkdown',
documentId: 'doc-1',
editor: mockEditor,
sourceType: 'notebook',
});
result.current.markDirty('doc-1');
});
await act(async () => {
await result.current.performSave('doc-1');
});
const expectedContent = `---
description: Skill metadata
name: skill-name
---
# Updated Skill`;
expect(documentService.updateDocument).toHaveBeenCalledWith(
expect.objectContaining({
content: expectedContent,
editorData: JSON.stringify(editorData),
id: 'doc-1',
}),
);
expect(result.current.documents['doc-1']).toMatchObject({
content: expectedContent,
isDirty: false,
lastSavedContent: expectedContent,
});
});
});
});
+4 -80
View File
@@ -8,7 +8,6 @@ import { EMPTY_EDITOR_STATE } from '@/libs/editor/constants';
import { isValidEditorData } from '@/libs/editor/isValidEditorData';
import { documentService } from '@/services/document';
import type { StoreSetter } from '@/store/types';
import { composeSkillMarkdown, parseSkillMarkdownFrontmatter } from '@/utils/skillMarkdown';
import { setNamespace } from '@/utils/storeDebug';
import type { DocumentStore } from '../../store';
@@ -44,23 +43,14 @@ export class EditorActionImpl {
this.#get = get;
}
private getPersistedMarkdown = (documentId: string | undefined, markdown: string): string => {
if (!documentId) return markdown;
const doc = this.#get().documents[documentId];
if (doc?.contentFormat !== 'skillMarkdown') return markdown;
return composeSkillMarkdown(doc.skillFrontmatter, markdown);
};
getEditorContent = (): { editorData: any; markdown: string } | null => {
const { activeDocumentId, editor } = this.#get();
const { editor } = this.#get();
if (!editor) return null;
try {
const markdown = (editor.getDocument('markdown') as unknown as string) || '';
const editorData = editor.getDocument('json');
return { editorData, markdown: this.getPersistedMarkdown(activeDocumentId, markdown) };
return { editorData, markdown };
} catch (error) {
console.error('[DocumentStore] Failed to get editor content:', error);
return null;
@@ -80,8 +70,7 @@ export class EditorActionImpl {
if (!doc) return false;
try {
const editorMarkdown = (editor.getDocument('markdown') as unknown as string) || '';
const markdown = this.getPersistedMarkdown(id, editorMarkdown);
const markdown = (editor.getDocument('markdown') as unknown as string) || '';
const editorData = editor.getDocument('json');
const markdownChanged = markdown !== doc.lastSavedContent;
@@ -124,44 +113,6 @@ export class EditorActionImpl {
this.syncEditorContent(undefined, { triggerAutoSave: true });
};
updateSkillFrontmatter = (documentId: string, frontmatter: string): boolean => {
const { activeDocumentId, documents, editor, internal_dispatchDocument } = this.#get();
const doc = documents[documentId];
if (!doc || doc.contentFormat !== 'skillMarkdown') return false;
try {
const isActiveDocument = activeDocumentId === documentId;
const body =
isActiveDocument && editor
? (editor.getDocument('markdown') as unknown as string) || ''
: parseSkillMarkdownFrontmatter(doc.content).body;
const editorData = isActiveDocument && editor ? editor.getDocument('json') : doc.editorData;
const content = composeSkillMarkdown(frontmatter, body);
const contentChanged = content !== doc.lastSavedContent;
const editorDataChanged = !isEqual(editorData, doc.lastSavedEditorData);
internal_dispatchDocument(
{
id: documentId,
type: 'updateDocument',
value: {
content,
editorData,
isDirty: contentChanged || editorDataChanged,
skillFrontmatter: frontmatter,
},
},
'updateSkillFrontmatter',
);
return true;
} catch (error) {
console.error('[DocumentStore] Failed to update SKILL.md frontmatter:', error);
return false;
}
};
internal_dispatchDocument = (payload: DocumentDispatch, action?: string): void => {
const { documents } = this.#get();
const nextDocuments = documentReducer(documents, payload);
@@ -196,31 +147,6 @@ export class EditorActionImpl {
typeof doc.editorData === 'object' &&
Object.keys(doc.editorData).length > 0;
// SKILL.md frontmatter is metadata, not editable document body. Keep it out of the rich
// Markdown editor because `---` fences are otherwise parsed as Markdown dividers/headings,
// then stitch the same YAML back into the persisted content during save.
if (doc.contentFormat === 'skillMarkdown') {
if (hasValidEditorData) {
try {
editor.setDocument('json', JSON.stringify(doc.editorData));
return;
} catch {
console.warn(
'[DocumentStore] Failed to load SKILL.md editorData, falling back to markdown',
);
}
}
try {
editor.setDocument('markdown', parseSkillMarkdownFrontmatter(doc.content).body);
this.#set({ editor });
} catch (err) {
console.error('[DocumentStore] Failed to load SKILL.md content:', err);
}
return;
}
// Set content from document state
if (hasValidEditorData) {
try {
@@ -267,8 +193,7 @@ export class EditorActionImpl {
internal_dispatchDocument({ id, type: 'updateDocument', value: { saveStatus: 'saving' } });
try {
const currentEditorMarkdown = (editor.getDocument('markdown') as unknown as string) || '';
const currentContent = this.getPersistedMarkdown(id, currentEditorMarkdown);
const currentContent = (editor.getDocument('markdown') as unknown as string) || '';
const currentEditorData = editor.getDocument('json');
if (!isValidEditorData(currentEditorData)) {
@@ -295,7 +220,6 @@ export class EditorActionImpl {
id,
type: 'updateDocument',
value: {
content: currentContent,
editorData: structuredClone(currentEditorData),
isDirty: false,
@@ -1,13 +1,12 @@
'use client';
import type { IEditor } from '@lobehub/editor';
import type { EditorState as LobehubEditorState } from '@lobehub/editor/react';
import { type IEditor } from '@lobehub/editor';
import { type EditorState as LobehubEditorState } from '@lobehub/editor/react';
/**
* Document source type - determines which service to use for persistence
*/
export type DocumentSourceType = 'notebook' | 'page';
export type DocumentContentFormat = 'markdown' | 'skillMarkdown';
/**
* Editor content state for a single document
@@ -23,10 +22,6 @@ export interface EditorContentState {
* Document content (markdown)
*/
content: string;
/**
* Content format used by the editor persistence pipeline.
*/
contentFormat?: DocumentContentFormat;
/**
* Editor JSON data (BlockNote format)
*/
@@ -52,11 +47,6 @@ export interface EditorContentState {
* Current save status
*/
saveStatus: 'idle' | 'saving' | 'saved';
/**
* YAML frontmatter for SKILL.md documents. It is kept outside the rich Markdown editor because
* the editor parses the closing `---` as a Setext heading underline and renders metadata as a giant heading.
*/
skillFrontmatter?: string;
/**
* Document source type - determines which service to call for persistence
*/
-183
View File
@@ -1,183 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import {
composeSkillMarkdown,
getSkillMarkdownMetadataError,
isSkillMarkdownDocument,
parseSkillMarkdownFrontmatter,
parseSkillMarkdownFrontmatterFields,
parseSkillMarkdownMetadata,
} from './skillMarkdown';
describe('skillMarkdown', () => {
it('extracts SKILL.md frontmatter and body', () => {
const content = `---
description: >-
Use when given a YouTube video link.
name: youtube-comment-retrieval-workflow
---
# Workflow`;
expect(parseSkillMarkdownFrontmatter(content)).toEqual({
body: '# Workflow',
frontmatter: `description: >-
Use when given a YouTube video link.
name: youtube-comment-retrieval-workflow`,
});
});
it('extracts SKILL.md frontmatter after a BOM or leading whitespace', () => {
const content = `\uFEFF
---
description: Skill metadata
name: skill-name
---
# Workflow`;
expect(parseSkillMarkdownFrontmatter(content)).toEqual({
body: '# Workflow',
frontmatter: `description: Skill metadata
name: skill-name`,
});
});
it('keeps ordinary Markdown unchanged when frontmatter is absent', () => {
const content = `
# Workflow`;
expect(parseSkillMarkdownFrontmatter(content)).toEqual({ body: content });
});
it('parses folded YAML scalars for display without control markers', () => {
expect(
parseSkillMarkdownMetadata(`description: >-
Use when given a YouTube video link
and retrieve comments.
name: youtube-comment-retrieval-workflow`),
).toEqual([
{
key: 'description',
value: 'Use when given a YouTube video link and retrieve comments.',
},
{
key: 'name',
value: 'youtube-comment-retrieval-workflow',
},
]);
});
it('stringifies non-string metadata values for display', () => {
expect(
parseSkillMarkdownMetadata(`enabled: true
retries: 2
resources:
- reference.md
optional:`),
).toEqual([
{ key: 'enabled', value: 'true' },
{ key: 'retries', value: '2' },
{ key: 'resources', value: '["reference.md"]' },
{ key: 'optional', value: '' },
]);
});
it('reads frontmatter fields used by the metadata editor', () => {
expect(
parseSkillMarkdownFrontmatterFields(`name: skill-name
description: >-
Use when given a YouTube video link
and retrieve comments.`),
).toEqual({
description: 'Use when given a YouTube video link and retrieve comments.',
name: 'skill-name',
});
});
it('returns empty frontmatter fields for invalid or non-mapping YAML', () => {
expect(parseSkillMarkdownFrontmatterFields('description: [')).toEqual({});
expect(parseSkillMarkdownFrontmatterFields('- name')).toEqual({});
expect(parseSkillMarkdownFrontmatterFields()).toEqual({});
});
it('recomposes frontmatter with the edited body', () => {
expect(composeSkillMarkdown('name: skill-name', '# Updated')).toBe(`---
name: skill-name
---
# Updated`);
});
it('recomposes edge cases without duplicating spacing', () => {
expect(composeSkillMarkdown(undefined, '# Body')).toBe('# Body');
expect(composeSkillMarkdown('name: skill-name\n', '')).toBe(`---
name: skill-name
---
`);
expect(composeSkillMarkdown('name: skill-name', '\n# Body')).toBe(`---
name: skill-name
---
# Body`);
});
it('detects SKILL.md documents from document metadata', () => {
expect(isSkillMarkdownDocument({ fileType: 'skills/index' })).toBe(true);
expect(isSkillMarkdownDocument({ filename: 'SKILL.md' })).toBe(true);
expect(isSkillMarkdownDocument({ title: 'SKILL.md' })).toBe(true);
expect(isSkillMarkdownDocument({ filename: 'README.md' })).toBe(false);
});
it('returns an empty metadata list for invalid YAML', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(parseSkillMarkdownMetadata('description: [')).toEqual([]);
consoleError.mockRestore();
});
it('validates editable YAML frontmatter', () => {
expect(
getSkillMarkdownMetadataError(`name: skill-name
description: Skill description`),
).toBeUndefined();
expect(getSkillMarkdownMetadataError('')).toEqual({ type: 'required' });
expect(getSkillMarkdownMetadataError('- name')).toEqual({ type: 'mapping' });
expect(getSkillMarkdownMetadataError('description: [')).toEqual({ type: 'syntax' });
expect(getSkillMarkdownMetadataError('description: Skill description')).toEqual({
type: 'nameRequired',
});
expect(
getSkillMarkdownMetadataError(`name: Skill Name
description: Skill description`),
).toEqual({ type: 'nameInvalid' });
expect(
getSkillMarkdownMetadataError(`name: 123
description: Skill description`),
).toEqual({ type: 'nameInvalid' });
expect(getSkillMarkdownMetadataError('name: skill-name')).toEqual({
type: 'descriptionRequired',
});
expect(
getSkillMarkdownMetadataError(`name: skill-name
description:
- list item`),
).toEqual({ type: 'descriptionInvalid' });
expect(
getSkillMarkdownMetadataError(`name: skill-name
description: |
Line 1
Line 2`),
).toEqual({ type: 'descriptionInvalid' });
expect(
getSkillMarkdownMetadataError(
`name: other-name
description: Skill description`,
{ expectedName: 'skill-name' },
),
).toEqual({ expectedName: 'skill-name', type: 'nameLocked' });
});
});
-200
View File
@@ -1,200 +0,0 @@
import { parse } from 'yaml';
const SKILL_INDEX_FILENAME = 'SKILL.md';
const SKILL_INDEX_FILE_TYPE = 'skills/index';
const SKILL_MARKDOWN_LEADING_WHITESPACE_REGEX = /^\uFEFF?[ \t]*(?:\r?\n[ \t]*)*/;
const SKILL_MARKDOWN_FRONTMATTER_REGEX =
/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/;
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MAX_SKILL_NAME_LENGTH = 80;
const UNSAFE_FRONTMATTER_SCALAR_PATTERN = /[\r\n]/;
interface SkillMarkdownDocumentFields {
filename?: string | null;
fileType?: string | null;
title?: string | null;
}
interface SkillMarkdownParts {
body: string;
frontmatter?: string;
}
export interface SkillMarkdownMetadataItem {
key: string;
value: string;
}
export interface SkillMarkdownFrontmatterFields {
description?: string;
name?: string;
}
interface SkillMarkdownMetadataValidationOptions {
expectedName?: string;
}
export type SkillMarkdownMetadataError =
| {
type: 'required';
}
| {
type: 'mapping';
}
| {
type: 'nameRequired';
}
| {
type: 'nameInvalid';
}
| {
expectedName: string;
type: 'nameLocked';
}
| {
type: 'descriptionRequired';
}
| {
type: 'descriptionInvalid';
}
| {
type: 'syntax';
};
export const isSkillMarkdownDocument = (document: SkillMarkdownDocumentFields): boolean =>
document.fileType === SKILL_INDEX_FILE_TYPE ||
document.filename === SKILL_INDEX_FILENAME ||
document.title === SKILL_INDEX_FILENAME;
export const parseSkillMarkdownFrontmatter = (content?: string | null): SkillMarkdownParts => {
if (!content) return { body: '' };
const normalizedContent = content.replace(SKILL_MARKDOWN_LEADING_WHITESPACE_REGEX, '');
const match = normalizedContent.match(SKILL_MARKDOWN_FRONTMATTER_REGEX);
if (!match) return { body: content };
return {
body: match[2].replace(/^\r?\n/, ''),
frontmatter: match[1],
};
};
export const composeSkillMarkdown = (frontmatter: string | undefined, body: string): string => {
if (!frontmatter) return body;
const normalizedFrontmatter = frontmatter.trimEnd();
if (!body) return `---\n${normalizedFrontmatter}\n---\n`;
return `---\n${normalizedFrontmatter}\n---\n\n${body.replace(/^\r?\n/, '')}`;
};
const stringifyMetadataValue = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return JSON.stringify(value);
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const isValidSkillName = (name: string): boolean =>
!!name &&
name.length <= MAX_SKILL_NAME_LENGTH &&
SKILL_NAME_PATTERN.test(name) &&
!name.includes('/') &&
!name.includes('\\') &&
name !== '.' &&
name !== '..';
const readFrontmatterStringField = (
data: Record<string, unknown>,
field: keyof SkillMarkdownFrontmatterFields,
): string | undefined => {
const value = data[field];
return typeof value === 'string' ? value.trim() : undefined;
};
export const parseSkillMarkdownFrontmatterFields = (
frontmatter?: string,
): SkillMarkdownFrontmatterFields => {
if (!frontmatter) return {};
try {
const parsed = parse(frontmatter) as unknown;
if (!isRecord(parsed)) return {};
return {
description: readFrontmatterStringField(parsed, 'description'),
name: readFrontmatterStringField(parsed, 'name'),
};
} catch {
return {};
}
};
export const getSkillMarkdownMetadataError = (
frontmatter?: string,
options?: SkillMarkdownMetadataValidationOptions,
): SkillMarkdownMetadataError | undefined => {
if (!frontmatter?.trim()) return { type: 'required' };
try {
const parsed = parse(frontmatter) as unknown;
if (!isRecord(parsed)) {
return { type: 'mapping' };
}
// Keep this client-side guard aligned with server skillManagement/frontmatter.ts so the
// metadata editor cannot persist SKILL.md frontmatter that later skill code rejects.
const nameValue = parsed.name;
if (nameValue === undefined || (typeof nameValue === 'string' && !nameValue.trim())) {
return { type: 'nameRequired' };
}
if (typeof nameValue !== 'string') return { type: 'nameInvalid' };
const name = nameValue.trim();
if (!isValidSkillName(name)) return { type: 'nameInvalid' };
const expectedName = options?.expectedName?.trim();
if (expectedName && isValidSkillName(expectedName) && name !== expectedName) {
return { expectedName, type: 'nameLocked' };
}
const descriptionValue = parsed.description;
if (
descriptionValue === undefined ||
(typeof descriptionValue === 'string' && !descriptionValue.trim())
) {
return { type: 'descriptionRequired' };
}
if (typeof descriptionValue !== 'string') return { type: 'descriptionInvalid' };
const description = descriptionValue.trim();
if (UNSAFE_FRONTMATTER_SCALAR_PATTERN.test(description)) {
return { type: 'descriptionInvalid' };
}
return undefined;
} catch {
return { type: 'syntax' };
}
};
export const parseSkillMarkdownMetadata = (frontmatter?: string): SkillMarkdownMetadataItem[] => {
if (!frontmatter) return [];
try {
const parsed = parse(frontmatter) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return [];
return Object.entries(parsed as Record<string, unknown>).map(([key, value]) => ({
key,
value: stringifyMetadataValue(value),
}));
} catch (error) {
console.error('[SkillMarkdown] Failed to parse SKILL.md frontmatter:', error);
return [];
}
};