mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 06:15:58 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f7a510c5e |
+1
-1
@@ -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 . .
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 会话只能在原目录下继续,已开始新对话。",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"batchDelete": "批量删除",
|
||||
"blog": "产品博客",
|
||||
"botIntegrationBanner.dismiss": "关闭",
|
||||
"botIntegrationBanner.title": "在你喜爱的聊天应用中,与 Lobe AI 畅聊。",
|
||||
"botIntegrationBanner.title": "为 LobeAI 添加渠道",
|
||||
"branching": "创建子话题",
|
||||
"branchingDisable": "「子话题」功能在当前模式下不可用。如需该功能,请切换到 Postgres/Pglite DB 模式或使用 LobeHub Cloud",
|
||||
"branchingRequiresSavedTopic": "当前话题未保存。保存后即可使用子话题",
|
||||
|
||||
@@ -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": "二级标题",
|
||||
|
||||
@@ -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": "即将卸载该技能,卸载后将清除该技能配置,请确认你的操作",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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';
|
||||
-13
@@ -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}
|
||||
|
||||
+3
-3
@@ -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;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -626,7 +626,6 @@ export const topicRouter = router({
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
repos: z.array(z.string()).optional(),
|
||||
workingDirectory: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
-1
@@ -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) => []),
|
||||
};
|
||||
|
||||
|
||||
-1
@@ -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) => []),
|
||||
};
|
||||
|
||||
|
||||
-109
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user