mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(skills): recognize project-level skills in the homogeneous agent runtime (#15110)
This commit is contained in:
@@ -962,7 +962,7 @@
|
||||
"workingPanel.resources.deleteError": "Failed to delete document",
|
||||
"workingPanel.resources.deleteSuccess": "Document deleted",
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.empty": "No documents yet. Documents associated with this agent will show up here.",
|
||||
"workingPanel.resources.empty": "No webpages. Webpages crawled in this agent will show up here.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.documents": "Documents",
|
||||
"workingPanel.resources.filter.skills": "Skills",
|
||||
@@ -1019,10 +1019,10 @@
|
||||
"workingPanel.review.viewMode.unified": "Switch to unified view",
|
||||
"workingPanel.review.wordWrap.disable": "Disable word wrap",
|
||||
"workingPanel.review.wordWrap.enable": "Enable word wrap",
|
||||
"workingPanel.skills.empty": "No skills found in this project",
|
||||
"workingPanel.skills.emptyAgent": "No skills attached to this agent",
|
||||
"workingPanel.skills.empty": "No skills available",
|
||||
"workingPanel.skills.section.agent": "Agent skills",
|
||||
"workingPanel.skills.section.project": "Project skills",
|
||||
"workingPanel.skills.section.user": "User skills",
|
||||
"workingPanel.skills.title": "Skills",
|
||||
"workingPanel.space": "Space",
|
||||
"workingPanel.title": "Working Panel",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"builtins.lobe-skill-store.render.repository": "Repository",
|
||||
"builtins.lobe-skill-store.render.version": "Version",
|
||||
"builtins.lobe-skill-store.title": "Skill Store",
|
||||
"builtins.lobe-skills.apiName.activateAgentSkill": "Activate Agent Skill",
|
||||
"builtins.lobe-skills.apiName.activateProjectSkill": "Activate Project Skill",
|
||||
"builtins.lobe-skills.apiName.activateSkill": "Activate Skill",
|
||||
"builtins.lobe-skills.apiName.execScript": "Run Script",
|
||||
"builtins.lobe-skills.apiName.exportFile": "Export File",
|
||||
|
||||
@@ -962,7 +962,7 @@
|
||||
"workingPanel.resources.deleteError": "删除失败",
|
||||
"workingPanel.resources.deleteSuccess": "文档已删除",
|
||||
"workingPanel.resources.deleteTitle": "删除该文档?",
|
||||
"workingPanel.resources.empty": "暂无文档。与此智能体关联的文档将显示在这里。",
|
||||
"workingPanel.resources.empty": "暂无网页。本智能体抓取的网页将显示在这里。",
|
||||
"workingPanel.resources.error": "资源加载失败",
|
||||
"workingPanel.resources.filter.documents": "文档",
|
||||
"workingPanel.resources.filter.skills": "技能",
|
||||
@@ -1019,10 +1019,10 @@
|
||||
"workingPanel.review.viewMode.unified": "切换到合并视图",
|
||||
"workingPanel.review.wordWrap.disable": "关闭自动换行",
|
||||
"workingPanel.review.wordWrap.enable": "启用自动换行",
|
||||
"workingPanel.skills.empty": "当前项目未发现技能",
|
||||
"workingPanel.skills.emptyAgent": "该智能体未附加技能",
|
||||
"workingPanel.skills.empty": "暂无可用技能",
|
||||
"workingPanel.skills.section.agent": "智能体技能",
|
||||
"workingPanel.skills.section.project": "项目技能",
|
||||
"workingPanel.skills.section.user": "用户技能",
|
||||
"workingPanel.skills.title": "技能",
|
||||
"workingPanel.space": "空间",
|
||||
"workingPanel.title": "工作面板",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"builtins.lobe-skill-store.render.repository": "存储库",
|
||||
"builtins.lobe-skill-store.render.version": "版本",
|
||||
"builtins.lobe-skill-store.title": "技能商店",
|
||||
"builtins.lobe-skills.apiName.activateAgentSkill": "激活 Agent Skill",
|
||||
"builtins.lobe-skills.apiName.activateProjectSkill": "激活 Project Skill",
|
||||
"builtins.lobe-skills.apiName.activateSkill": "激活技能",
|
||||
"builtins.lobe-skills.apiName.execScript": "执行脚本",
|
||||
"builtins.lobe-skills.apiName.exportFile": "导出文件",
|
||||
|
||||
@@ -3,12 +3,31 @@
|
||||
import { type BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { type TFunction } from 'i18next';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { ActivateSkillParams, ActivateSkillState } from '../../../types';
|
||||
import type { ActivateSkillParams, ActivateSkillSource, ActivateSkillState } from '../../../types';
|
||||
|
||||
/**
|
||||
* `t` is invoked with literal keys per branch so i18next's typed-key map can
|
||||
* still validate the call site.
|
||||
*/
|
||||
const resolveLabel = (t: TFunction<'plugin'>, source: ActivateSkillSource | undefined): string => {
|
||||
switch (source) {
|
||||
case 'agent': {
|
||||
return t('builtins.lobe-skills.apiName.activateAgentSkill');
|
||||
}
|
||||
case 'project': {
|
||||
return t('builtins.lobe-skills.apiName.activateProjectSkill');
|
||||
}
|
||||
default: {
|
||||
return t('builtins.lobe-skills.apiName.activateSkill');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
@@ -50,19 +69,20 @@ export const ActivateSkillInspector = memo<
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const name = args?.name || partialArgs?.name;
|
||||
const displayName = pluginState?.name || name;
|
||||
const displayName = pluginState?.title || pluginState?.name || name;
|
||||
const label = resolveLabel(t, pluginState?.source);
|
||||
|
||||
if (isArgumentsStreaming) {
|
||||
if (!displayName)
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
<span>{label}:</span>
|
||||
<span className={styles.chip}>
|
||||
<SkillsIcon className={styles.skillIcon} size={12} />
|
||||
<span className={styles.skillName}>{displayName}</span>
|
||||
@@ -73,7 +93,7 @@ export const ActivateSkillInspector = memo<
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
<span>{label}:</span>
|
||||
{displayName && (
|
||||
<span className={styles.chip}>
|
||||
<SkillsIcon className={styles.skillIcon} size={12} />
|
||||
|
||||
@@ -38,14 +38,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
const ActivateSkill = memo<BuiltinRenderProps<ActivateSkillParams, ActivateSkillState>>(
|
||||
({ content, pluginState }) => {
|
||||
const { description, name } = pluginState || {};
|
||||
const { description, name, title } = pluginState || {};
|
||||
const displayName = title || name;
|
||||
|
||||
if (!name) return null;
|
||||
if (!displayName) return null;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<Flexbox className={styles.header} gap={4}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={styles.name}>{displayName}</span>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
</Flexbox>
|
||||
{content && (
|
||||
|
||||
@@ -3,6 +3,7 @@ export { systemPrompt } from './systemRole';
|
||||
export {
|
||||
type ActivatedToolInfo,
|
||||
type ActivateSkillParams,
|
||||
type ActivateSkillSource,
|
||||
type ActivateSkillState,
|
||||
type ActivateToolsParams,
|
||||
type ActivateToolsState,
|
||||
|
||||
@@ -27,9 +27,15 @@ export interface ActivateSkillParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type ActivateSkillSource = 'agent' | 'builtin' | 'project' | 'user';
|
||||
|
||||
export interface ActivateSkillState {
|
||||
description?: string;
|
||||
hasResources: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
/** Skill origin — drives the inspector label (e.g. "Activate Agent Skill"). */
|
||||
source?: ActivateSkillSource;
|
||||
/** Friendly title for UI display; falls back to `name` when unset. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -182,4 +182,165 @@ describe('SkillsExecutionRuntime', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('project skills', () => {
|
||||
const projectSkill = {
|
||||
location: '/repo/.agents/skills/deploy/SKILL.md',
|
||||
name: 'deploy',
|
||||
};
|
||||
|
||||
it('activateSkill reads SKILL.md and appends a directory hint for lazy discovery', async () => {
|
||||
const readFile = vi.fn().mockResolvedValue('# Deploy\nRun the deploy steps.');
|
||||
const listFiles = vi.fn().mockResolvedValue([]);
|
||||
const runtime = new SkillsExecutionRuntime({
|
||||
deviceFileAccess: { listFiles, readFile },
|
||||
projectSkills: [projectSkill],
|
||||
service: createMockService(),
|
||||
});
|
||||
|
||||
const result = await runtime.activateSkill({ name: 'deploy' });
|
||||
|
||||
expect(readFile).toHaveBeenCalledWith('/repo/.agents/skills/deploy/SKILL.md');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('Run the deploy steps.');
|
||||
// The hint points at the skill's directory and instructs the model to
|
||||
// call `local-system.listFiles` itself rather than pre-enumerating here.
|
||||
expect(result.content).toContain('/repo/.agents/skills/deploy');
|
||||
expect(result.content).toContain('listFiles');
|
||||
expect(result.state).toMatchObject({ name: 'deploy', source: 'project' });
|
||||
});
|
||||
|
||||
it('activateSkill takes precedence over a same-named DB skill', async () => {
|
||||
const readFile = vi.fn().mockResolvedValue('project content');
|
||||
const listFiles = vi.fn().mockResolvedValue([]);
|
||||
const findByName = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: 'db content', id: 'x', name: 'deploy' });
|
||||
const runtime = new SkillsExecutionRuntime({
|
||||
deviceFileAccess: { listFiles, readFile },
|
||||
projectSkills: [projectSkill],
|
||||
service: createMockService({ findByName }),
|
||||
});
|
||||
|
||||
const result = await runtime.activateSkill({ name: 'deploy' });
|
||||
|
||||
expect(result.content).toContain('project content');
|
||||
expect(findByName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('activateSkill fails clearly when no device file access is available', async () => {
|
||||
const runtime = new SkillsExecutionRuntime({
|
||||
projectSkills: [projectSkill],
|
||||
service: createMockService(),
|
||||
});
|
||||
|
||||
const result = await runtime.activateSkill({ name: 'deploy' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.content).toContain('no device file access');
|
||||
});
|
||||
|
||||
it('readReference resolves a project file relative to the SKILL.md directory', async () => {
|
||||
const readFile = vi.fn().mockResolvedValue('print("run")');
|
||||
const listFiles = vi.fn().mockResolvedValue(['SKILL.md', 'scripts/run.py']);
|
||||
const runtime = new SkillsExecutionRuntime({
|
||||
deviceFileAccess: { listFiles, readFile },
|
||||
projectSkills: [projectSkill],
|
||||
service: createMockService(),
|
||||
});
|
||||
|
||||
const result = await runtime.readReference({ id: 'deploy', path: 'scripts/run.py' });
|
||||
|
||||
expect(listFiles).toHaveBeenCalledWith('/repo/.agents/skills/deploy');
|
||||
expect(readFile).toHaveBeenCalledWith('/repo/.agents/skills/deploy/scripts/run.py');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toBe('print("run")');
|
||||
});
|
||||
|
||||
it('readReference rejects paths not in the declared skill file list', async () => {
|
||||
const readFile = vi.fn();
|
||||
const listFiles = vi.fn().mockResolvedValue(['SKILL.md', 'scripts/run.py']);
|
||||
const runtime = new SkillsExecutionRuntime({
|
||||
deviceFileAccess: { listFiles, readFile },
|
||||
projectSkills: [projectSkill],
|
||||
service: createMockService(),
|
||||
});
|
||||
|
||||
const result = await runtime.readReference({ id: 'deploy', path: 'secrets.json' });
|
||||
|
||||
expect(listFiles).toHaveBeenCalledWith('/repo/.agents/skills/deploy');
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.content).toContain('Resource not found in project skill');
|
||||
});
|
||||
|
||||
it('readReference rejects hidden segments before consulting the device', async () => {
|
||||
const readFile = vi.fn();
|
||||
const listFiles = vi.fn();
|
||||
const runtime = new SkillsExecutionRuntime({
|
||||
deviceFileAccess: { listFiles, readFile },
|
||||
projectSkills: [projectSkill],
|
||||
service: createMockService(),
|
||||
});
|
||||
|
||||
const result = await runtime.readReference({ id: 'deploy', path: '.env' });
|
||||
|
||||
expect(listFiles).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.content).toContain('not a permitted skill resource');
|
||||
});
|
||||
});
|
||||
|
||||
describe('activation precedence', () => {
|
||||
it('DB skill wins over a same-named builtin (matches injection dedupe)', async () => {
|
||||
const findByName = vi.fn().mockResolvedValue({
|
||||
content: 'user-authored content',
|
||||
description: 'user skill',
|
||||
id: 'user-1',
|
||||
name: 'overlap',
|
||||
});
|
||||
const runtime = new SkillsExecutionRuntime({
|
||||
builtinSkills: [
|
||||
{
|
||||
content: 'builtin content',
|
||||
description: 'builtin skill',
|
||||
identifier: 'overlap',
|
||||
name: 'overlap',
|
||||
source: 'builtin',
|
||||
},
|
||||
],
|
||||
service: createMockService({ findByName }),
|
||||
});
|
||||
|
||||
const result = await runtime.activateSkill({ name: 'overlap' });
|
||||
|
||||
expect(findByName).toHaveBeenCalledWith('overlap');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('user-authored content');
|
||||
expect(result.state).toMatchObject({ name: 'overlap', source: 'user' });
|
||||
});
|
||||
|
||||
it('falls through to builtin when no DB skill exists', async () => {
|
||||
const findByName = vi.fn().mockResolvedValue(undefined);
|
||||
const runtime = new SkillsExecutionRuntime({
|
||||
builtinSkills: [
|
||||
{
|
||||
content: 'builtin only',
|
||||
description: 'builtin skill',
|
||||
identifier: 'artifacts',
|
||||
name: 'artifacts',
|
||||
source: 'builtin',
|
||||
},
|
||||
],
|
||||
service: createMockService({ findByName }),
|
||||
});
|
||||
|
||||
const result = await runtime.activateSkill({ name: 'artifacts' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('builtin only');
|
||||
expect(result.state).toMatchObject({ name: 'artifacts', source: 'builtin' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AGENT_SKILLS_IDENTIFIER_PREFIX } from '@lobechat/const';
|
||||
import { formatCommandResult, resourcesTreePrompt } from '@lobechat/prompts';
|
||||
import type {
|
||||
BuiltinServerRuntimeOutput,
|
||||
@@ -52,18 +53,96 @@ export interface SkillRuntimeService {
|
||||
runCommand?: (options: RunCommandOptions) => Promise<CommandResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A project-level skill discovered on the device filesystem
|
||||
* (`.agents/skills` / `.claude/skills`). The runtime only needs the name to
|
||||
* match an `activateSkill`/`readReference` call and the absolute SKILL.md path
|
||||
* to read its content. The directory tree is enumerated lazily on activation
|
||||
* via `DeviceFileAccess.listFiles` — keeping it out of the op param payload.
|
||||
*/
|
||||
export interface ProjectSkillRuntimeItem {
|
||||
/** Absolute path to the skill's SKILL.md on the device. */
|
||||
location: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device filesystem access used to load project skills. The server wires this
|
||||
* to the `local-system` tool over the device gateway, so the runtime stays
|
||||
* transport-agnostic — it just needs to read/enumerate files on the device.
|
||||
*/
|
||||
export interface DeviceFileAccess {
|
||||
/**
|
||||
* Recursively enumerate files under `dir`, returning POSIX-style paths
|
||||
* relative to `dir`. `readReference` validates user-supplied paths against
|
||||
* this list so the model can only read files the project skill actually
|
||||
* exposes (no hidden files, no escape outside the skill directory).
|
||||
*/
|
||||
listFiles: (dir: string) => Promise<string[]>;
|
||||
/** Read a text file's content from the device. */
|
||||
readFile: (path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface SkillsExecutionRuntimeOptions {
|
||||
builtinSkills?: BuiltinSkill[];
|
||||
/** Reads project skill files from the device (local-system over the gateway). */
|
||||
deviceFileAccess?: DeviceFileAccess;
|
||||
/** Project skills discovered on the device filesystem. */
|
||||
projectSkills?: ProjectSkillRuntimeItem[];
|
||||
service: SkillRuntimeService;
|
||||
}
|
||||
|
||||
/** Cross-platform dirname for absolute paths (POSIX or Windows separators). */
|
||||
const getDirname = (filePath: string): string => {
|
||||
const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
return idx === -1 ? '' : filePath.slice(0, idx);
|
||||
};
|
||||
|
||||
/** Join a directory with a relative path, preserving the directory's separator. */
|
||||
const joinPath = (dir: string, rel: string): string => {
|
||||
const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
|
||||
const trimmed = dir.endsWith(sep) ? dir.slice(0, -sep.length) : dir;
|
||||
return `${trimmed}${sep}${rel}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a user-supplied relative path to POSIX form: backslashes → `/`,
|
||||
* trim leading `./` and slashes. Used to compare requested paths against the
|
||||
* skill's `listFiles` result (which is canonicalized the same way by the
|
||||
* device-side enumerator) and to detect hidden segments.
|
||||
*/
|
||||
const normalizeRelativePath = (rel: string): string =>
|
||||
rel
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^(?:\.\/)+/, '')
|
||||
.replace(/^\/+/, '');
|
||||
|
||||
/** True when any segment in `rel` is hidden (starts with `.`) — `.env`, `.git/...`. */
|
||||
const hasHiddenSegment = (rel: string): boolean =>
|
||||
rel.split('/').some((seg) => seg.startsWith('.'));
|
||||
|
||||
/**
|
||||
* Hint appended to activated project-skill content so the model knows how to
|
||||
* discover the rest of the skill's directory. We deliberately don't enumerate
|
||||
* the tree here — the model has `local-system.listFiles` available and can
|
||||
* call it on demand, which keeps the op-param payload small.
|
||||
*/
|
||||
const buildProjectDirectoryHint = (skillName: string, skillDir: string): string =>
|
||||
`## Skill resources
|
||||
|
||||
This project skill lives in \`${skillDir}\`. Use \`local-system.listFiles\` on that path to discover reference files, then \`readReference\` with skillName="${skillName}" + the relative path to load any of them.`;
|
||||
|
||||
export class SkillsExecutionRuntime {
|
||||
private builtinSkills: BuiltinSkill[];
|
||||
private projectSkills: ProjectSkillRuntimeItem[];
|
||||
private deviceFileAccess?: DeviceFileAccess;
|
||||
private service: SkillRuntimeService;
|
||||
|
||||
constructor(options: SkillsExecutionRuntimeOptions) {
|
||||
this.service = options.service;
|
||||
this.builtinSkills = options.builtinSkills || [];
|
||||
this.projectSkills = options.projectSkills || [];
|
||||
this.deviceFileAccess = options.deviceFileAccess;
|
||||
}
|
||||
|
||||
async execScript(args: ExecScriptParams): Promise<BuiltinServerRuntimeOutput> {
|
||||
@@ -168,16 +247,87 @@ export class SkillsExecutionRuntime {
|
||||
async readReference(args: ReadReferenceParams): Promise<BuiltinServerRuntimeOutput> {
|
||||
const { id, path } = args;
|
||||
|
||||
// Validate path to prevent traversal attacks
|
||||
if (path.includes('..')) {
|
||||
return {
|
||||
content: 'Invalid path: path traversal is not allowed',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Check builtin skills first
|
||||
// Project skills resolve references relative to the SKILL.md directory,
|
||||
// read through the device file access (local-system over the gateway).
|
||||
const projectSkill = this.projectSkills.find((s) => s.name === id);
|
||||
if (projectSkill) {
|
||||
if (!this.deviceFileAccess) {
|
||||
return {
|
||||
content: `Project skill "${id}" cannot be read: no device file access available.`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize and reject obviously-unsafe shapes up front. The
|
||||
// `listFiles` membership check below is the real authority, but
|
||||
// failing fast here keeps the error message specific.
|
||||
const normalized = normalizeRelativePath(path);
|
||||
if (!normalized || normalized.includes('..') || hasHiddenSegment(normalized)) {
|
||||
return {
|
||||
content: `Invalid path: "${path}" is not a permitted skill resource`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Enumerate the skill directory and only allow files the device
|
||||
// surface advertises. Without this, a model could request any path
|
||||
// under the skill dir (e.g. `.env`, `node_modules/…`) that was never
|
||||
// declared as a skill resource. The device-side enumerator already
|
||||
// filters hidden files; we re-check here as defense in depth.
|
||||
const skillDir = getDirname(projectSkill.location);
|
||||
const allowed = new Set(
|
||||
(await this.deviceFileAccess.listFiles(skillDir)).map((f) => normalizeRelativePath(f)),
|
||||
);
|
||||
if (!allowed.has(normalized)) {
|
||||
return {
|
||||
content: `Resource not found in project skill "${id}": "${path}"`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const fullPath = joinPath(skillDir, normalized);
|
||||
const content = await this.deviceFileAccess.readFile(fullPath);
|
||||
return {
|
||||
content,
|
||||
state: { encoding: 'utf8', fileType: 'text/plain', fullPath, path: normalized },
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// For non-project skills, keep the traversal guard. Builtin / user
|
||||
// skills look paths up via an explicit `resources` map or service, so
|
||||
// the `..` substring is the only realistic traversal vector.
|
||||
if (path.includes('..')) {
|
||||
return {
|
||||
content: 'Invalid path: path traversal is not allowed',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// DB (user-level) skills win over builtins on name collision — matches
|
||||
// the `<available_skills>` dedupe precedence (project > user > agent >
|
||||
// builtin) in `aiAgent/index.ts`. Without this, the model would see a
|
||||
// user skill in the list but `readReference` would silently read the
|
||||
// shadowed builtin's resources.
|
||||
const skill = await this.service.findByName(id);
|
||||
if (skill) {
|
||||
const resource = await this.service.readResource(skill.id, path);
|
||||
return {
|
||||
content: resource.content,
|
||||
state: {
|
||||
encoding: resource.encoding,
|
||||
fileType: resource.fileType,
|
||||
fullPath: resource.fullPath,
|
||||
path: resource.path,
|
||||
size: resource.size,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to builtin skills (includes agent-document skill bundles
|
||||
// via the `agent-skills:` identifier prefix).
|
||||
const builtinSkill = this.builtinSkills.find((s) => s.name === id);
|
||||
if (builtinSkill?.resources) {
|
||||
const meta = builtinSkill.resources[path];
|
||||
@@ -199,26 +349,9 @@ export class SkillsExecutionRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve id: try findByName for DB skills
|
||||
const skill = await this.service.findByName(id);
|
||||
if (!skill) {
|
||||
return {
|
||||
content: `Skill not found: "${id}"`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const resource = await this.service.readResource(skill.id, path);
|
||||
return {
|
||||
content: resource.content,
|
||||
state: {
|
||||
encoding: resource.encoding,
|
||||
fileType: resource.fileType,
|
||||
fullPath: resource.fullPath,
|
||||
path: resource.path,
|
||||
size: resource.size,
|
||||
},
|
||||
success: true,
|
||||
content: `Skill not found: "${id}"`,
|
||||
success: false,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
@@ -231,7 +364,75 @@ export class SkillsExecutionRuntime {
|
||||
async activateSkill(args: ActivateSkillParams): Promise<BuiltinServerRuntimeOutput> {
|
||||
const { name } = args;
|
||||
|
||||
// Check builtin skills first — no DB query needed
|
||||
// Project skills (filesystem SKILL.md) take precedence over db/builtin.
|
||||
const projectSkill = this.projectSkills.find((s) => s.name === name);
|
||||
if (projectSkill) {
|
||||
if (!this.deviceFileAccess) {
|
||||
return {
|
||||
content: `Project skill "${name}" cannot be loaded: no device file access available.`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let content = await this.deviceFileAccess.readFile(projectSkill.location);
|
||||
|
||||
// Don't enumerate the directory here — let the model do it on demand
|
||||
// via `local-system.listFiles`. Just point at the skill's directory so
|
||||
// it knows where to look. Keeps the op-param payload small and avoids
|
||||
// a second deviceProxy round-trip at activation time.
|
||||
const skillDir = getDirname(projectSkill.location);
|
||||
if (skillDir) {
|
||||
content += '\n\n' + buildProjectDirectoryHint(name, skillDir);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
state: {
|
||||
hasResources: false,
|
||||
location: projectSkill.location,
|
||||
name,
|
||||
source: 'project',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
content: `Failed to load project skill "${name}": ${(e as Error).message}`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// DB (user-level) skills win over builtins on name collision — matches
|
||||
// the `<available_skills>` dedupe precedence (project > user > agent >
|
||||
// builtin) in `aiAgent/index.ts`. Without this, the model would see a
|
||||
// user skill in the list but `activateSkill` would silently load the
|
||||
// shadowed builtin instead.
|
||||
const skill = await this.service.findByName(name);
|
||||
if (skill) {
|
||||
const hasResources = !!(skill.resources && Object.keys(skill.resources).length > 0);
|
||||
let content = skill.content || '';
|
||||
|
||||
if (hasResources && skill.resources) {
|
||||
content += '\n\n' + resourcesTreePrompt(skill.name, skill.resources);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
state: {
|
||||
description: skill.description || undefined,
|
||||
hasResources,
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
source: 'user',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to builtin skills (includes agent-document skill bundles via
|
||||
// the `agent-skills:` identifier prefix).
|
||||
const builtinSkill = this.builtinSkills.find((s) => s.name === name);
|
||||
if (builtinSkill) {
|
||||
let content = builtinSkill.content;
|
||||
@@ -243,6 +444,12 @@ export class SkillsExecutionRuntime {
|
||||
content += '\n\n' + resourcesTreePrompt(builtinSkill.name, builtinSkill.resources);
|
||||
}
|
||||
|
||||
// Agent-document skill bundles flow through the builtin path with the
|
||||
// `agent-skills:` prefix on their identifier. Tag the result so the
|
||||
// inspector can pick the right label ("Activate Agent Skill") and prefer
|
||||
// the friendly `title` over the raw `agent-skills:<filename>` name.
|
||||
const isAgentSkill = builtinSkill.identifier.startsWith(AGENT_SKILLS_IDENTIFIER_PREFIX);
|
||||
|
||||
return {
|
||||
content,
|
||||
state: {
|
||||
@@ -250,43 +457,22 @@ export class SkillsExecutionRuntime {
|
||||
hasResources,
|
||||
identifier: builtinSkill.identifier,
|
||||
name: builtinSkill.name,
|
||||
source: isAgentSkill ? 'agent' : 'builtin',
|
||||
...(builtinSkill.title && { title: builtinSkill.title }),
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to DB query for user/market skills
|
||||
const skill = await this.service.findByName(name);
|
||||
|
||||
if (!skill) {
|
||||
const { data: allSkills } = await this.service.findAll();
|
||||
const availableSkills = allSkills.map((s) => ({
|
||||
description: s.description,
|
||||
name: s.name,
|
||||
}));
|
||||
|
||||
return {
|
||||
content: `Skill not found: "${name}". Available skills: ${JSON.stringify(availableSkills)}`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hasResources = !!(skill.resources && Object.keys(skill.resources).length > 0);
|
||||
let content = skill.content || '';
|
||||
|
||||
if (hasResources && skill.resources) {
|
||||
content += '\n\n' + resourcesTreePrompt(skill.name, skill.resources);
|
||||
}
|
||||
const { data: allSkills } = await this.service.findAll();
|
||||
const availableSkills = allSkills.map((s) => ({
|
||||
description: s.description,
|
||||
name: s.name,
|
||||
}));
|
||||
|
||||
return {
|
||||
content,
|
||||
state: {
|
||||
description: skill.description || undefined,
|
||||
hasResources,
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
},
|
||||
success: true,
|
||||
content: `Skill not found: "${name}". Available skills: ${JSON.stringify(availableSkills)}`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { AGENT_SKILLS_IDENTIFIER_PREFIX } from '@lobechat/const';
|
||||
import { type BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { type TFunction } from 'i18next';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { ActivateSkillParams, ActivateSkillState } from '../../../types';
|
||||
import type { ActivateSkillParams, ActivateSkillSource, ActivateSkillState } from '../../../types';
|
||||
|
||||
/**
|
||||
* Resolve the inspector label. State-side `source` is the authority once the
|
||||
* tool result has streamed in; while args are still streaming we only have the
|
||||
* raw `name` to go on, so detect agent skills via the identifier prefix as a
|
||||
* best-effort fallback. Project skills can't be inferred from the bare name
|
||||
* (no prefix), so they show "Activate Skill" until the result lands.
|
||||
*
|
||||
* `t` is invoked with literal keys per branch so i18next's typed-key map can
|
||||
* still validate the call site.
|
||||
*/
|
||||
const resolveLabel = (
|
||||
t: TFunction<'plugin'>,
|
||||
source: ActivateSkillSource | undefined,
|
||||
rawName: string | undefined,
|
||||
): string => {
|
||||
const effective: ActivateSkillSource =
|
||||
source ?? (rawName?.startsWith(AGENT_SKILLS_IDENTIFIER_PREFIX) ? 'agent' : 'builtin');
|
||||
|
||||
switch (effective) {
|
||||
case 'agent': {
|
||||
return t('builtins.lobe-skills.apiName.activateAgentSkill');
|
||||
}
|
||||
case 'project': {
|
||||
return t('builtins.lobe-skills.apiName.activateProjectSkill');
|
||||
}
|
||||
default: {
|
||||
return t('builtins.lobe-skills.apiName.activateSkill');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
@@ -50,19 +83,20 @@ export const RunSkillInspector = memo<
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const name = args?.name || partialArgs?.name;
|
||||
const displayName = pluginState?.name || name;
|
||||
const displayName = pluginState?.title || pluginState?.name || name;
|
||||
const label = resolveLabel(t, pluginState?.source, name);
|
||||
|
||||
if (isArgumentsStreaming) {
|
||||
if (!displayName)
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
<span>{label}:</span>
|
||||
<span className={styles.chip}>
|
||||
<SkillsIcon className={styles.skillIcon} size={12} />
|
||||
<span className={styles.skillName}>{displayName}</span>
|
||||
@@ -73,7 +107,7 @@ export const RunSkillInspector = memo<
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
<span>{label}:</span>
|
||||
{displayName && (
|
||||
<span className={styles.chip}>
|
||||
<SkillsIcon className={styles.skillIcon} size={12} />
|
||||
|
||||
@@ -38,14 +38,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
|
||||
const RunSkill = memo<BuiltinRenderProps<ActivateSkillParams, ActivateSkillState>>(
|
||||
({ content, pluginState }) => {
|
||||
const { description, name } = pluginState || {};
|
||||
const { description, name, title } = pluginState || {};
|
||||
const displayName = title || name;
|
||||
|
||||
if (!name) return null;
|
||||
if (!displayName) return null;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<Flexbox className={styles.header} gap={4}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<span className={styles.name}>{displayName}</span>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
</Flexbox>
|
||||
{content && (
|
||||
|
||||
@@ -2,6 +2,8 @@ export { SkillsManifest } from './manifest';
|
||||
export { systemPrompt } from './systemRole';
|
||||
export {
|
||||
type ActivateSkillParams,
|
||||
type ActivateSkillSource,
|
||||
type ActivateSkillState,
|
||||
type CommandResult,
|
||||
type ExecScriptActivatedSkill,
|
||||
type ExecScriptParams,
|
||||
|
||||
@@ -12,11 +12,17 @@ export interface ActivateSkillParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type ActivateSkillSource = 'agent' | 'builtin' | 'project' | 'user';
|
||||
|
||||
export interface ActivateSkillState {
|
||||
description?: string;
|
||||
hasResources: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
/** Skill origin — drives the inspector label (e.g. "Activate Agent Skill"). */
|
||||
source?: ActivateSkillSource;
|
||||
/** Friendly title for UI display; falls back to `name` when unset. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type SkillItem, skillsPrompts } from '@lobechat/prompts';
|
||||
import { type SkillItem, type SkillSource, skillsPrompts } from '@lobechat/prompts';
|
||||
import debug from 'debug';
|
||||
|
||||
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
|
||||
@@ -34,6 +34,11 @@ export interface SkillMeta {
|
||||
identifier: string;
|
||||
location?: string;
|
||||
name: string;
|
||||
/**
|
||||
* Skill origin. `project` skills are discovered on the device filesystem and
|
||||
* loaded on demand via the readFile tool (see `location`).
|
||||
*/
|
||||
source?: SkillSource;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +93,7 @@ export class SkillContextProvider extends BaseSystemRoleProvider {
|
||||
identifier: skill.identifier,
|
||||
location: skill.location,
|
||||
name: skill.name,
|
||||
source: skill.source,
|
||||
}));
|
||||
|
||||
const availableSkillsContent = skillsPrompts(skills);
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
export { buildResourcesTreeText, resourcesTreePrompt } from './resourcesTree';
|
||||
|
||||
export type SkillSource = 'builtin' | 'project' | 'user';
|
||||
|
||||
export interface SkillItem {
|
||||
description: string;
|
||||
identifier: string;
|
||||
location?: string;
|
||||
name: string;
|
||||
/**
|
||||
* Where the skill comes from. `project` skills live on the device filesystem
|
||||
* (e.g. `.agents/skills/<name>/SKILL.md`) and `location` carries their absolute
|
||||
* path so the model can load them via the readFile tool.
|
||||
*/
|
||||
source?: SkillSource;
|
||||
}
|
||||
|
||||
export const skillPrompt = (skill: SkillItem) =>
|
||||
skill.location
|
||||
? ` <skill name="${skill.name}" location="${skill.location}">${skill.description}</skill>`
|
||||
: ` <skill name="${skill.name}">${skill.description}</skill>`;
|
||||
export const skillPrompt = (skill: SkillItem) => {
|
||||
const attrs = [`name="${skill.name}"`];
|
||||
if (skill.source) attrs.push(`source="${skill.source}"`);
|
||||
if (skill.location) attrs.push(`location="${skill.location}"`);
|
||||
return ` <skill ${attrs.join(' ')}>${skill.description}</skill>`;
|
||||
};
|
||||
|
||||
export const skillsPrompts = (skills: SkillItem[]) => {
|
||||
if (skills.length === 0) return '';
|
||||
|
||||
const skillTags = skills.map((skill) => skillPrompt(skill)).join('\n');
|
||||
|
||||
const hasProjectSkill = skills.some((skill) => skill.source === 'project');
|
||||
const projectHint = hasProjectSkill
|
||||
? `\nFor a skill with source="project", load it by calling the readFile tool on its \`location\` path before following its instructions.`
|
||||
: '';
|
||||
|
||||
return `<available_skills>
|
||||
${skillTags}
|
||||
</available_skills>
|
||||
|
||||
Use the runSkill tool to activate a skill when needed.`;
|
||||
Use the runSkill tool to activate a skill when needed.${projectHint}`;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,22 @@ export interface ExecAgentAppContext {
|
||||
topicId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A project-level skill discovered on the device filesystem
|
||||
* (`.agents/skills` / `.claude/skills`) by the client at request time.
|
||||
* Only frontmatter + the absolute SKILL.md path are carried; the SKILL.md
|
||||
* body and directory tree are loaded on demand at activation time via the
|
||||
* readFile / listFiles tools.
|
||||
*/
|
||||
export interface ProjectSkillMeta {
|
||||
/** Skill description from SKILL.md frontmatter. */
|
||||
description?: string;
|
||||
/** Skill name from frontmatter (falls back to the directory name). */
|
||||
name: string;
|
||||
/** Absolute path to the skill's SKILL.md on the device filesystem. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for execAgent - execute a single Agent
|
||||
* Either agentId or slug must be provided
|
||||
@@ -71,6 +87,13 @@ export interface ExecAgentParams {
|
||||
* sub-tree back to its root.
|
||||
*/
|
||||
parentOperationId?: string;
|
||||
/**
|
||||
* Project-level skills discovered on the device filesystem
|
||||
* (`.agents/skills` / `.claude/skills`) at request time. Surfaced in the
|
||||
* `<available_skills>` list and loaded on demand via the readFile tool.
|
||||
* Only applied when a device is active for this run.
|
||||
*/
|
||||
projectSkills?: ProjectSkillMeta[];
|
||||
/** The user input/prompt */
|
||||
prompt: string;
|
||||
/** Override the agent's default provider */
|
||||
|
||||
@@ -53,6 +53,14 @@ export interface BuiltinSkill {
|
||||
*/
|
||||
resources?: Record<string, SkillResourceMeta>;
|
||||
source: 'builtin';
|
||||
/**
|
||||
* Optional friendly title for UI display. When unset, the inspector and
|
||||
* render layers fall back to `name` (which carries the raw identifier).
|
||||
* Agent-document skill bundles (`agent-skills:<filename>`) set this so the
|
||||
* activateSkill result shows e.g. "LOBE Annotation Cleanup" instead of
|
||||
* the raw `agent-skills:lobe-annotation-cleanup`.
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// ===== Skill Source =====
|
||||
|
||||
@@ -59,21 +59,23 @@ export class ActionTagPlugin {
|
||||
// Wire format collapses to <skill>; the `agent-skills:`
|
||||
// prefix in the identifier is what the runtime keys off to
|
||||
// route the activation through agentDocumentsService.
|
||||
// ProjectSkill → <skill name="<skill-name>" label="..." />
|
||||
// Same wire format as a registered skill — the project
|
||||
// skill is in the runtime's `<available_skills>` registry
|
||||
// (added on the server when a device is active), so the
|
||||
// model resolves it through `activateSkill` like any
|
||||
// other. Keeps the rendered prompt uniform across skill
|
||||
// sources, which the LiteXML round-trip preserves via the
|
||||
// category-aware `<projectSkill>` save format.
|
||||
// Tools → <tool name="..." label="..." />
|
||||
// ProjectSkill → bare label text (e.g. `/local-testing`) so the downstream
|
||||
// CLI agent recognizes its own slash-style skill invocation
|
||||
// Commands → <action type="..." category="command" label="..." />
|
||||
mdService?.registerMarkdownWriter(ActionTagNode.getType(), (ctx: any, node: any) => {
|
||||
if ($isActionTagNode(node)) {
|
||||
const cat = node.actionCategory;
|
||||
if (cat === 'skill' || cat === 'agentSkill') {
|
||||
if (cat === 'skill' || cat === 'agentSkill' || cat === 'projectSkill') {
|
||||
ctx.appendLine(`<skill name="${node.actionType}" label="${node.actionLabel}" />`);
|
||||
} else if (cat === 'tool') {
|
||||
ctx.appendLine(`<tool name="${node.actionType}" label="${node.actionLabel}" />`);
|
||||
} else if (cat === 'projectSkill') {
|
||||
// Chip / menu render the bare skill name; the slash is added here so
|
||||
// the downstream CLI sees `/skill-name` as a slash invocation.
|
||||
ctx.appendLine(`/${node.actionType}`);
|
||||
} else {
|
||||
ctx.appendLine(
|
||||
`<action type="${node.actionType}" category="${cat}" label="${node.actionLabel}" />`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isDesktop } from '@lobechat/const';
|
||||
import { type ListProjectSkillsResult, type ProjectSkillItem } from '@lobechat/electron-client-ipc';
|
||||
import type { IEditor, SlashOptions } from '@lobehub/editor';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import Fuse from 'fuse.js';
|
||||
import { $getSelection, $isRangeSelection } from 'lexical';
|
||||
import { ArchiveIcon, MessageSquarePlusIcon } from 'lucide-react';
|
||||
@@ -14,6 +15,9 @@ import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { agentDocumentSkillsSelectors } from '@/store/tool/selectors';
|
||||
import type { AgentDocumentSkillItem } from '@/store/tool/slices/agentDocumentSkills/initialState';
|
||||
|
||||
import { useAgentId } from '../../hooks/useAgentId';
|
||||
import { useChatInputStore } from '../../store';
|
||||
@@ -41,26 +45,33 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
|
||||
const editorInstance = useChatInputStore((s) => s.editor);
|
||||
const activeTopicId = useChatStore((s) => s.activeTopicId);
|
||||
|
||||
// Resolve hetero-agent working directory so we can surface its project skills.
|
||||
// Topic-level override takes precedence over the agent's configured cwd.
|
||||
// Resolve the active working directory so we can surface filesystem project
|
||||
// skills. Topic-level override takes precedence over the agent's configured
|
||||
// cwd. Both homogeneous and heterogeneous runtimes accept project skills now
|
||||
// (see commit dd4a4e7595), so we no longer gate on the hetero provider.
|
||||
const agentId = useAgentId();
|
||||
const isHetero = useAgentStore((s) =>
|
||||
agentId ? !!agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider : false,
|
||||
);
|
||||
const agentWorkingDirectory = useAgentStore((s) =>
|
||||
agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined,
|
||||
);
|
||||
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
const workingDirectory = topicWorkingDirectory || agentWorkingDirectory;
|
||||
|
||||
const skillsEnabled = isDesktop && isHetero && !!workingDirectory;
|
||||
const projectSkillsEnabled = isDesktop && !!workingDirectory;
|
||||
const { data: projectSkillsData } = useClientDataSWR<ListProjectSkillsResult>(
|
||||
skillsEnabled ? ['project-skills', workingDirectory] : null,
|
||||
projectSkillsEnabled ? ['project-skills', workingDirectory] : null,
|
||||
() => localFileService.listProjectSkills({ scope: workingDirectory! }),
|
||||
{ revalidateOnFocus: false, shouldRetryOnError: false },
|
||||
);
|
||||
const projectSkills = projectSkillsData?.skills;
|
||||
|
||||
// Agent-document skill bundles (the "Agent skills" group in the working
|
||||
// sidebar). Share the SWR key with the sidebar fetch so we don't double-fetch.
|
||||
useToolStore((s) => s.useFetchAgentDocumentSkills)(agentId);
|
||||
const agentDocumentSkills = useToolStore(
|
||||
agentDocumentSkillsSelectors.getAgentDocumentSkills,
|
||||
isEqual,
|
||||
);
|
||||
|
||||
// Installed skills shared with the @ mention menu (builtin / lobehub / market / user agent skills).
|
||||
// Tools intentionally stay out of slash — they remain @-mention only.
|
||||
const installedSkillsAndTools = useInstalledSkillsAndTools();
|
||||
@@ -126,6 +137,30 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
|
||||
},
|
||||
});
|
||||
|
||||
const makeAgentSkillItem = (skill: AgentDocumentSkillItem): SlashMenuOption => {
|
||||
const label = skill.title || skill.name;
|
||||
return {
|
||||
icon: SkillsIcon,
|
||||
// Identifier already carries the `agent-skills:` prefix, which keeps it
|
||||
// unique against project / builtin skills.
|
||||
key: `agent-skill-${skill.identifier}`,
|
||||
label,
|
||||
metadata: {
|
||||
category: 'agentSkill',
|
||||
description: skill.description,
|
||||
type: skill.identifier,
|
||||
},
|
||||
onSelect: (editor: IEditor) => {
|
||||
const payload: InsertActionTagPayload = {
|
||||
category: 'agentSkill',
|
||||
label,
|
||||
type: skill.identifier,
|
||||
};
|
||||
editor.dispatchCommand(INSERT_ACTION_TAG_COMMAND, payload);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Trigger position:
|
||||
// - line-start → commands + installed skills + project skills
|
||||
// - mid-line w/ preceding whitespace → installed skills + project skills only (no commands)
|
||||
@@ -175,7 +210,16 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
|
||||
allItems.push(makeSkillItem(skill) as SlashItem);
|
||||
}
|
||||
|
||||
// Hetero-agent project skills (file-system based, resolved by the CLI agent itself)
|
||||
// Agent-document skill bundles (per-agent, resolved server-side via the
|
||||
// `agent-skills:<filename>` identifier prefix).
|
||||
for (const skill of agentDocumentSkills) {
|
||||
allItems.push(makeAgentSkillItem(skill) as SlashItem);
|
||||
}
|
||||
|
||||
// Filesystem project skills (`.agents/skills/` / `.claude/skills/` under
|
||||
// the working directory). Both homogeneous and heterogeneous runtimes
|
||||
// resolve them — the homogeneous runtime treats them as additional
|
||||
// `<available_skills>` entries.
|
||||
if (projectSkills && projectSkills.length > 0) {
|
||||
for (const skill of projectSkills) {
|
||||
allItems.push(makeProjectSkillItem(skill) as SlashItem);
|
||||
@@ -190,6 +234,6 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
|
||||
|
||||
return allItems;
|
||||
},
|
||||
[t, editorInstance, activeTopicId, projectSkills, installedSkills],
|
||||
[t, editorInstance, activeTopicId, projectSkills, installedSkills, agentDocumentSkills],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,8 +7,16 @@ import { memo, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
export interface SkillListItem {
|
||||
description?: string;
|
||||
fileCount: number;
|
||||
files: string[];
|
||||
/**
|
||||
* File count shown next to the name. Omit (or pass 0) for atomic skills with
|
||||
* no embedded files — e.g. user-level skills sourced from MCP servers.
|
||||
*/
|
||||
fileCount?: number;
|
||||
/**
|
||||
* Optional file tree for skills that bundle markdown/script assets. When
|
||||
* empty or omitted the row stays atomic (no chevron, no expansion).
|
||||
*/
|
||||
files?: string[];
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
@@ -221,7 +229,9 @@ interface SkillRowProps {
|
||||
|
||||
const SkillRow = memo<SkillRowProps>(
|
||||
({ expanded, item, onDragStart, onOpenFile, onOpenSkill, onToggle }) => {
|
||||
const tree = useMemo(() => buildSkillTree(item.files), [item.files]);
|
||||
const files = item.files ?? [];
|
||||
const hasFiles = files.length > 0;
|
||||
const tree = useMemo(() => buildSkillTree(files), [files]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const toggleFolder = useCallback((folderPath: string) => {
|
||||
@@ -251,29 +261,36 @@ const SkillRow = memo<SkillRowProps>(
|
||||
gap={6}
|
||||
onDragStart={onDragStart}
|
||||
>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
style={{ cursor: 'pointer', flexShrink: 0, height: 20, width: 20 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ''}`}
|
||||
icon={ChevronRightIcon}
|
||||
size={14}
|
||||
/>
|
||||
</Flexbox>
|
||||
{hasFiles ? (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
style={{ cursor: 'pointer', flexShrink: 0, height: 20, width: 20 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className={`${styles.chevron} ${expanded ? styles.chevronExpanded : ''}`}
|
||||
icon={ChevronRightIcon}
|
||||
size={14}
|
||||
/>
|
||||
</Flexbox>
|
||||
) : (
|
||||
<span style={{ flexShrink: 0, height: 20, width: 20 }} />
|
||||
)}
|
||||
<Icon className={styles.itemIcon} icon={SkillsIcon} size={14} />
|
||||
<Text ellipsis style={{ color: 'inherit', flex: 1, minWidth: 0 }} onClick={onOpenSkill}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<span className={styles.itemCount}>{item.fileCount}</span>
|
||||
{typeof item.fileCount === 'number' && item.fileCount > 0 && (
|
||||
<span className={styles.itemCount}>{item.fileCount}</span>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Tooltip>
|
||||
{expanded &&
|
||||
hasFiles &&
|
||||
onOpenFile &&
|
||||
tree.map((node) => (
|
||||
<TreeRow
|
||||
|
||||
@@ -1019,8 +1019,7 @@ export default {
|
||||
'workingPanel.resources.deleteError': 'Failed to delete document',
|
||||
'workingPanel.resources.deleteSuccess': 'Document deleted',
|
||||
'workingPanel.resources.deleteTitle': 'Delete document?',
|
||||
'workingPanel.resources.empty':
|
||||
'No documents yet. Documents associated with this agent will show up here.',
|
||||
'workingPanel.resources.empty': 'No webpages. Webpages crawled in this agent will show up here.',
|
||||
'workingPanel.resources.error': 'Failed to load resources',
|
||||
'workingPanel.resources.filter.documents': 'Documents',
|
||||
'workingPanel.resources.filter.skills': 'Skills',
|
||||
@@ -1051,10 +1050,10 @@ export default {
|
||||
'workingPanel.localFile.preview.raw': 'Raw',
|
||||
'workingPanel.localFile.preview.render': 'Preview',
|
||||
'workingPanel.localFile.truncated': 'File preview truncated to {{limit}} characters',
|
||||
'workingPanel.skills.empty': 'No skills found in this project',
|
||||
'workingPanel.skills.emptyAgent': 'No skills attached to this agent',
|
||||
'workingPanel.skills.empty': 'No skills available',
|
||||
'workingPanel.skills.section.agent': 'Agent skills',
|
||||
'workingPanel.skills.section.project': 'Project skills',
|
||||
'workingPanel.skills.section.user': 'User skills',
|
||||
'workingPanel.skills.title': 'Skills',
|
||||
'workingPanel.files.count_one': '{{count}} file',
|
||||
'workingPanel.files.count_other': '{{count}} files',
|
||||
|
||||
@@ -267,6 +267,8 @@ export default {
|
||||
'builtins.lobe-skills.apiName.readReference': 'Read Reference',
|
||||
'builtins.lobe-skills.apiName.runCommand': 'Run Command',
|
||||
'builtins.lobe-skills.apiName.activateSkill': 'Activate Skill',
|
||||
'builtins.lobe-skills.apiName.activateAgentSkill': 'Activate Agent Skill',
|
||||
'builtins.lobe-skills.apiName.activateProjectSkill': 'Activate Project Skill',
|
||||
'builtins.lobe-skills.apiName.searchSkill': 'Search Skills',
|
||||
'builtins.lobe-skills.title': 'Skills',
|
||||
'builtins.lobe-task.apiName.createTask': 'Create task',
|
||||
|
||||
+30
-7
@@ -72,6 +72,9 @@ vi.mock('react-i18next', () => ({
|
||||
'workingPanel.resources.filter.web': 'Web',
|
||||
'workingPanel.resources.updatedAt': `Updated ${options?.time}`,
|
||||
'workingPanel.skills.empty': 'No skills found',
|
||||
'workingPanel.skills.section.agent': 'Agent skills',
|
||||
'workingPanel.skills.section.project': 'Project skills',
|
||||
'workingPanel.skills.section.user': 'User skills',
|
||||
}) as Record<string, string>
|
||||
)[key] || key,
|
||||
}),
|
||||
@@ -89,7 +92,7 @@ vi.mock('@/features/AgentDocumentsExplorer', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/features/SkillsList', () => {
|
||||
type Item = { fileCount: number; id: string; name: string };
|
||||
type Item = { fileCount?: number; id: string; name: string };
|
||||
const SkillsList = ({
|
||||
items,
|
||||
onOpenFile,
|
||||
@@ -114,15 +117,19 @@ vi.mock('@/features/SkillsList', () => {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
// SkillSection is a thin presentational wrapper — the real one only adds
|
||||
// an Accordion / header. For these tests we pass children through and
|
||||
// surface the count so assertions that key off the section header still
|
||||
// work.
|
||||
// SkillSection is a thin presentational wrapper — the real one swaps the
|
||||
// children for an empty placeholder when `isEmpty` is true. We mirror that
|
||||
// here so co-located assertions (e.g. "skills-list does not render when no
|
||||
// bundles are present") line up with production behavior.
|
||||
const SkillSection = ({
|
||||
children,
|
||||
emptyText,
|
||||
isEmpty,
|
||||
sectionHeader,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
emptyText?: string;
|
||||
isEmpty?: boolean;
|
||||
sectionHeader?: { count?: number; title: string };
|
||||
}) => (
|
||||
<div data-testid={sectionHeader ? `skill-section-${sectionHeader.title}` : 'skill-section'}>
|
||||
@@ -132,7 +139,7 @@ vi.mock('@/features/SkillsList', () => {
|
||||
{typeof sectionHeader.count === 'number' && <span>{sectionHeader.count}</span>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{isEmpty ? <div data-testid="skill-section-empty">{emptyText}</div> : children}
|
||||
</div>
|
||||
);
|
||||
const useProjectSkills = () => ({
|
||||
@@ -145,6 +152,19 @@ vi.mock('@/features/SkillsList', () => {
|
||||
return { SkillSection, SkillsList, useProjectSkills };
|
||||
});
|
||||
|
||||
// UserLevelSkills owns its own store wiring and is exercised separately. We
|
||||
// stub it (component + the lifted hook) so AgentDocumentsGroup's render logic
|
||||
// can be tested without dragging the tool store into the working-sidebar
|
||||
// tests. Default to an empty user-skill list so the empty-state branch is
|
||||
// reachable; individual tests can re-mock before render to override.
|
||||
vi.mock('./UserLevelSkills', () => ({
|
||||
default: () => null,
|
||||
useUserSkills: () => [],
|
||||
}));
|
||||
vi.mock('@/features/ChatInput/InputEditor/ActionTag/skillDragData', () => ({
|
||||
startSkillDrag: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/agentDocument', () => ({
|
||||
agentDocumentSWRKeys: {
|
||||
documents: (agentId: string) => ['agent-documents', agentId],
|
||||
@@ -393,7 +413,7 @@ describe('AgentDocumentsGroup', () => {
|
||||
expect(openDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the skills empty state when no bundles are present', () => {
|
||||
it('falls back to a single empty placeholder when every skill source is empty', () => {
|
||||
useClientDataSWR.mockReturnValue({
|
||||
data: [fileDocRow, webDocRow],
|
||||
error: undefined,
|
||||
@@ -403,6 +423,9 @@ describe('AgentDocumentsGroup', () => {
|
||||
|
||||
render(<AgentDocumentsGroup />);
|
||||
|
||||
// No agent bundles, no working dir (no Project section), no user-installed
|
||||
// skills → renderSkills collapses to the global "No skills found"
|
||||
// placeholder rather than rendering an empty section per source.
|
||||
expect(screen.getByText('No skills found')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('skills-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
+51
-25
@@ -15,7 +15,12 @@ import { useMatch, useNavigate } from 'react-router-dom';
|
||||
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
|
||||
import { DocumentExplorerTree } from '@/features/AgentDocumentsExplorer';
|
||||
import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skillDragData';
|
||||
import { type SkillListItem, SkillSection, SkillsList } from '@/features/SkillsList';
|
||||
import {
|
||||
type SkillListItem,
|
||||
SkillSection,
|
||||
SkillsList,
|
||||
useProjectSkills,
|
||||
} from '@/features/SkillsList';
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
@@ -24,6 +29,7 @@ import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import ProjectLevelSkills from './ProjectLevelSkills';
|
||||
import UserLevelSkills, { useUserSkills } from './UserLevelSkills';
|
||||
|
||||
const PAGE_ROUTE_PATTERN = '/agent/:aid/:topicId/page/:docId?';
|
||||
|
||||
@@ -272,6 +278,14 @@ const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ style, workingDire
|
||||
|
||||
const showProjectSkills = isLocalEnabled && !!workingDirectory;
|
||||
|
||||
// Mirror what each child component reads so the parent can decide the
|
||||
// section layout (flat when a single source has items, sectioned otherwise).
|
||||
// Both hooks are SWR-deduped against their respective child fetches.
|
||||
const userSkillItems = useUserSkills();
|
||||
const { items: projectSkillItems } = useProjectSkills(
|
||||
showProjectSkills ? workingDirectory : undefined,
|
||||
);
|
||||
|
||||
const {
|
||||
data = [],
|
||||
error,
|
||||
@@ -358,33 +372,45 @@ const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ style, workingDire
|
||||
);
|
||||
|
||||
const renderSkills = () => {
|
||||
// No project section (not local mode / no working dir): show the agent
|
||||
// skills flat, without the redundant "Agent skills" group header.
|
||||
if (!showProjectSkills) {
|
||||
if (skillItems.length === 0) {
|
||||
return (
|
||||
<Center flex={1} gap={8} paddingBlock={24}>
|
||||
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return renderAgentSkillsList();
|
||||
// Sections render in fixed order — agent → project → user — and each one
|
||||
// hides itself when it has nothing to show. When exactly one source has
|
||||
// items we drop the group header and render the list flat (no redundant
|
||||
// "User skills 1" label above a single row). When everything is empty we
|
||||
// fall back to a single placeholder.
|
||||
const hasAgent = skillItems.length > 0;
|
||||
const hasProject = showProjectSkills && projectSkillItems.length > 0;
|
||||
const hasUser = userSkillItems.length > 0;
|
||||
const activeCount = (hasAgent ? 1 : 0) + (hasProject ? 1 : 0) + (hasUser ? 1 : 0);
|
||||
|
||||
if (activeCount === 0) {
|
||||
return (
|
||||
<Center flex={1} gap={8} paddingBlock={24}>
|
||||
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// Both sections coexist — label each so the source is clear.
|
||||
const flat = activeCount === 1;
|
||||
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
<SkillSection
|
||||
emptyText={t('workingPanel.skills.emptyAgent')}
|
||||
isEmpty={skillItems.length === 0}
|
||||
sectionHeader={{
|
||||
count: skillItems.length,
|
||||
title: t('workingPanel.skills.section.agent'),
|
||||
}}
|
||||
>
|
||||
{renderAgentSkillsList()}
|
||||
</SkillSection>
|
||||
<ProjectLevelSkills workingDirectory={workingDirectory!} />
|
||||
<Flexbox gap={16} style={{ paddingBottom: 16 }}>
|
||||
{hasAgent &&
|
||||
(flat ? (
|
||||
renderAgentSkillsList()
|
||||
) : (
|
||||
<SkillSection
|
||||
sectionHeader={{
|
||||
count: skillItems.length,
|
||||
title: t('workingPanel.skills.section.agent'),
|
||||
}}
|
||||
>
|
||||
{renderAgentSkillsList()}
|
||||
</SkillSection>
|
||||
))}
|
||||
{hasProject && (
|
||||
<ProjectLevelSkills hideHeader={flat} workingDirectory={workingDirectory!} />
|
||||
)}
|
||||
{hasUser && <UserLevelSkills hideHeader={flat} />}
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
+29
-19
@@ -5,37 +5,47 @@ import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skill
|
||||
import { SkillSection, SkillsList, useProjectSkills } from '@/features/SkillsList';
|
||||
|
||||
interface ProjectLevelSkillsProps {
|
||||
/**
|
||||
* Skip the `SkillSection` wrapper (no header row). Set when the parent has
|
||||
* collapsed to a single visible source and wants the list rendered flat.
|
||||
*/
|
||||
hideHeader?: boolean;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const ProjectLevelSkills = memo<ProjectLevelSkillsProps>(({ workingDirectory }) => {
|
||||
const ProjectLevelSkills = memo<ProjectLevelSkillsProps>(({ hideHeader, workingDirectory }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { isLoading, items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory);
|
||||
const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const list = (
|
||||
<SkillsList
|
||||
items={items}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenSkill={onOpenSkill}
|
||||
onSkillDragStart={(item, event) => {
|
||||
// Project skills are resolved by the underlying CLI agent itself, so
|
||||
// we serialize them as a literal `/skill-name` (projectSkill chip).
|
||||
startSkillDrag(event, {
|
||||
category: 'projectSkill',
|
||||
label: item.name,
|
||||
type: item.name,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (hideHeader) return list;
|
||||
|
||||
return (
|
||||
<SkillSection
|
||||
emptyText={t('workingPanel.skills.empty')}
|
||||
isEmpty={items.length === 0}
|
||||
isLoading={isLoading}
|
||||
sectionHeader={{
|
||||
count: items.length,
|
||||
title: t('workingPanel.skills.section.project'),
|
||||
}}
|
||||
>
|
||||
<SkillsList
|
||||
items={items}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenSkill={onOpenSkill}
|
||||
onSkillDragStart={(item, event) => {
|
||||
// Project skills are resolved by the underlying CLI agent itself, so
|
||||
// we serialize them as a literal `/skill-name` (projectSkill chip).
|
||||
startSkillDrag(event, {
|
||||
category: 'projectSkill',
|
||||
label: item.name,
|
||||
type: item.name,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{list}
|
||||
</SkillSection>
|
||||
);
|
||||
});
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skillDragData';
|
||||
import { type SkillListItem, SkillSection, SkillsList } from '@/features/SkillsList';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { agentSkillsSelectors } from '@/store/tool/selectors';
|
||||
|
||||
/**
|
||||
* Reads user-installed skills (entries in the `agent_skill` table — market
|
||||
* imports plus user-created customs) into the `SkillsList` row shape. Builtin
|
||||
* tools and LobeHub MCP servers are intentionally excluded — those belong in
|
||||
* the Tools popover, not in the per-user skill inventory.
|
||||
*
|
||||
* Also triggers the underlying SWR fetch so the working sidebar surfaces the
|
||||
* data even when the Tools popover hasn't been opened in this session. The key
|
||||
* is deduplicated, so co-mounting with `useControls` doesn't double-fetch.
|
||||
*/
|
||||
export const useUserSkills = (): SkillListItem[] => {
|
||||
useToolStore((s) => s.useFetchAgentSkills)(true);
|
||||
const agentSkills = useToolStore(agentSkillsSelectors.getAgentSkills, isEqual);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
agentSkills.map((skill) => ({
|
||||
description: skill.description ?? undefined,
|
||||
// `identifier` is what the runtime resolves through the skill registry,
|
||||
// and is unique per skill — reuse it as both the React key and the
|
||||
// drag payload's `type`.
|
||||
id: skill.identifier,
|
||||
name: skill.name,
|
||||
})),
|
||||
[agentSkills],
|
||||
);
|
||||
};
|
||||
|
||||
interface UserLevelSkillsProps {
|
||||
/**
|
||||
* Skip the `SkillSection` wrapper (no header row). Set when the parent has
|
||||
* collapsed to a single visible source and wants the list rendered flat,
|
||||
* matching the agent-only layout this used to ship with.
|
||||
*/
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
const UserLevelSkills = memo<UserLevelSkillsProps>(({ hideHeader }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const items = useUserSkills();
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const list = (
|
||||
<SkillsList
|
||||
items={items}
|
||||
onSkillDragStart={(item, event) => {
|
||||
startSkillDrag(event, {
|
||||
category: 'skill',
|
||||
label: item.name,
|
||||
type: item.id,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (hideHeader) return list;
|
||||
|
||||
return (
|
||||
<SkillSection
|
||||
sectionHeader={{
|
||||
count: items.length,
|
||||
title: t('workingPanel.skills.section.user'),
|
||||
}}
|
||||
>
|
||||
{list}
|
||||
</SkillSection>
|
||||
);
|
||||
});
|
||||
|
||||
UserLevelSkills.displayName = 'UserLevelSkills';
|
||||
|
||||
export default UserLevelSkills;
|
||||
@@ -1705,6 +1705,15 @@ export const createRuntimeExecutors = (
|
||||
memoryToolPermission: agentConfig?.chatConfig?.memory?.toolPermission,
|
||||
messageId: state.metadata?.sourceMessageId,
|
||||
operationId,
|
||||
projectSkills: (state.metadata?.operationSkillSet?.skills ?? [])
|
||||
.filter(
|
||||
(skill: { location?: string; source?: string }) =>
|
||||
skill.source === 'project' && !!skill.location,
|
||||
)
|
||||
.map((skill: { location: string; name: string }) => ({
|
||||
location: skill.location,
|
||||
name: skill.name,
|
||||
})),
|
||||
scope: state.metadata?.scope,
|
||||
serverDB: ctx.serverDB,
|
||||
skipResultTruncation: true,
|
||||
|
||||
@@ -160,6 +160,20 @@ const ExecAgentSchema = z
|
||||
fileIds: z.array(z.string()).optional(),
|
||||
/** Parent message ID for regeneration/continue (skip user message creation, branch from this message) */
|
||||
parentMessageId: z.string().optional(),
|
||||
/**
|
||||
* Project-level skills discovered on the device filesystem
|
||||
* (`.agents/skills` / `.claude/skills`) by the client at request time.
|
||||
* Surfaced in `<available_skills>` and loaded on demand via readFile.
|
||||
*/
|
||||
projectSkills: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string().optional(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
/** The user input/prompt */
|
||||
prompt: z.string(),
|
||||
/**
|
||||
@@ -624,6 +638,7 @@ export const aiAgentRouter = router({
|
||||
existingMessageIds = [],
|
||||
fileIds,
|
||||
parentMessageId,
|
||||
projectSkills,
|
||||
resumeApproval,
|
||||
trigger,
|
||||
userInterventionConfig,
|
||||
@@ -641,6 +656,7 @@ export const aiAgentRouter = router({
|
||||
existingMessageIds,
|
||||
fileIds,
|
||||
parentMessageId,
|
||||
projectSkills,
|
||||
prompt,
|
||||
// When parentMessageId is provided, this is a regeneration/continue or a
|
||||
// human-approval resume — either way, skip user message creation.
|
||||
|
||||
@@ -650,4 +650,191 @@ describe('AgentDocumentsService', () => {
|
||||
expect(result).toEqual({ id: 'ad-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentSkills', () => {
|
||||
// Inject docs with the derive flags already set so we test the
|
||||
// bundle → index-child → identifier mapping in isolation, not the
|
||||
// model's deriveAgentDocumentFields projection.
|
||||
const stubDocs = (docs: Array<Partial<any>>): any[] =>
|
||||
docs.map((doc) => ({
|
||||
content: '',
|
||||
description: null,
|
||||
filename: '',
|
||||
isSkillBundle: false,
|
||||
isSkillIndex: false,
|
||||
parentId: null,
|
||||
title: null,
|
||||
...doc,
|
||||
}));
|
||||
|
||||
it('returns an empty list when the agent has no skill bundles', async () => {
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
|
||||
stubDocs([
|
||||
{ documentId: 'doc-1', filename: 'note.md', isSkillBundle: false },
|
||||
{ documentId: 'doc-2', filename: 'web.md', isSkillBundle: false },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.getAgentSkills('agent-1');
|
||||
|
||||
expect(service.getAgentDocuments).toHaveBeenCalledWith('agent-1');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('prefixes the identifier with `agent-skills:` and pulls content from the SKILL.md index child', async () => {
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
|
||||
stubDocs([
|
||||
{
|
||||
content: '',
|
||||
description: 'Triage workflow',
|
||||
documentId: 'bundle-1',
|
||||
filename: 'bug-triage',
|
||||
isSkillBundle: true,
|
||||
title: 'Bug Triage',
|
||||
},
|
||||
{
|
||||
content: '# Bug triage\n\nbody',
|
||||
documentId: 'index-1',
|
||||
filename: 'SKILL.md',
|
||||
isSkillIndex: true,
|
||||
parentId: 'bundle-1',
|
||||
},
|
||||
// Sibling non-index child — must be ignored.
|
||||
{
|
||||
content: 'reference',
|
||||
documentId: 'asset-1',
|
||||
filename: 'reference.md',
|
||||
parentId: 'bundle-1',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.getAgentSkills('agent-1');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
content: '# Bug triage\n\nbody',
|
||||
description: 'Triage workflow',
|
||||
filename: 'bug-triage',
|
||||
identifier: 'agent-skills:bug-triage',
|
||||
name: 'agent-skills:bug-triage',
|
||||
title: 'Bug Triage',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the bundle row content when the index child is missing', async () => {
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
|
||||
stubDocs([
|
||||
{
|
||||
content: 'orphan body',
|
||||
description: null,
|
||||
documentId: 'orphan-1',
|
||||
filename: 'orphan-skill',
|
||||
isSkillBundle: true,
|
||||
title: 'Orphan',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.getAgentSkills('agent-1');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
content: 'orphan body',
|
||||
description: '',
|
||||
filename: 'orphan-skill',
|
||||
identifier: 'agent-skills:orphan-skill',
|
||||
name: 'agent-skills:orphan-skill',
|
||||
title: 'Orphan',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits empty content for a bundle with no index child and no body', async () => {
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
|
||||
stubDocs([
|
||||
{
|
||||
content: '',
|
||||
documentId: 'empty-1',
|
||||
filename: 'empty',
|
||||
isSkillBundle: true,
|
||||
title: 'Empty',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const [skill] = await service.getAgentSkills('agent-1');
|
||||
|
||||
expect(skill.content).toBe('');
|
||||
expect(skill.identifier).toBe('agent-skills:empty');
|
||||
});
|
||||
|
||||
it('returns one entry per skill bundle and ignores non-bundle docs', async () => {
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
|
||||
stubDocs([
|
||||
{
|
||||
documentId: 'b-1',
|
||||
filename: 'one',
|
||||
isSkillBundle: true,
|
||||
title: 'One',
|
||||
},
|
||||
{
|
||||
content: 'one body',
|
||||
documentId: 'b-1-idx',
|
||||
isSkillIndex: true,
|
||||
parentId: 'b-1',
|
||||
},
|
||||
{
|
||||
documentId: 'b-2',
|
||||
filename: 'two',
|
||||
isSkillBundle: true,
|
||||
title: 'Two',
|
||||
},
|
||||
{
|
||||
content: 'two body',
|
||||
documentId: 'b-2-idx',
|
||||
isSkillIndex: true,
|
||||
parentId: 'b-2',
|
||||
},
|
||||
// Unrelated regular doc.
|
||||
{ documentId: 'note', filename: 'note.md' },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.getAgentSkills('agent-1');
|
||||
|
||||
expect(result.map((s) => s.identifier)).toEqual(['agent-skills:one', 'agent-skills:two']);
|
||||
expect(result.map((s) => s.content)).toEqual(['one body', 'two body']);
|
||||
});
|
||||
|
||||
it('matches index children strictly by parentId — does not leak across bundles', async () => {
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
|
||||
stubDocs([
|
||||
{ documentId: 'b-1', filename: 'first', isSkillBundle: true },
|
||||
{ documentId: 'b-2', filename: 'second', isSkillBundle: true },
|
||||
// Only b-2 has an index child; b-1 must fall back to its own (empty)
|
||||
// content rather than borrow b-2's content.
|
||||
{
|
||||
content: 'second body',
|
||||
documentId: 'b-2-idx',
|
||||
isSkillIndex: true,
|
||||
parentId: 'b-2',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.getAgentSkills('agent-1');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.find((s) => s.filename === 'first')?.content).toBe('');
|
||||
expect(result.find((s) => s.filename === 'second')?.content).toBe('second body');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2069,9 +2069,39 @@ export class AiAgentService {
|
||||
name: skill.name,
|
||||
}));
|
||||
|
||||
// Project skills are filesystem SKILL.md discovered on the device. They
|
||||
// are only meaningful when a device is active (readFile resolves against
|
||||
// it). Only `location` (absolute SKILL.md path) flows through — the
|
||||
// skill's directory tree is enumerated lazily at activation time via
|
||||
// `local-system.listFiles` over the device gateway, keeping the op-param
|
||||
// payload small.
|
||||
const projectMetas =
|
||||
activeDeviceId && params.projectSkills?.length
|
||||
? params.projectSkills.map((s) => ({
|
||||
description: s.description ?? '',
|
||||
identifier: `project:${s.name}`,
|
||||
location: s.path,
|
||||
name: s.name,
|
||||
source: 'project' as const,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Precedence on name collision: project > db > agent-skills > builtin.
|
||||
// Agent-skills carry the `agent-skills:` prefix in their `name`, so they
|
||||
// can only collide with each other — but we still dedupe by name to keep
|
||||
// a single shape for the SkillEngine input.
|
||||
const seenNames = new Set<string>();
|
||||
const skills = [...projectMetas, ...dbMetas, ...agentSkillMetas, ...builtinMetas].filter(
|
||||
(skill) => {
|
||||
if (seenNames.has(skill.name)) return false;
|
||||
seenNames.add(skill.name);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const skillEngine = new SkillEngine({
|
||||
enableChecker: (skill) => shouldEnableBuiltinSkill(skill.identifier),
|
||||
skills: [...builtinMetas, ...dbMetas, ...agentSkillMetas],
|
||||
skills,
|
||||
});
|
||||
operationSkillSet = skillEngine.generate(agentPlugins ?? []);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { builtinSkills } from '@lobechat/builtin-skills';
|
||||
import { LocalSystemApiName, LocalSystemIdentifier } from '@lobechat/builtin-tool-local-system';
|
||||
// Note: only `readFile` is wired through deviceProxy. Directory enumeration is
|
||||
// left to the model via `local-system.listFiles` so we don't double-fetch.
|
||||
import { type CommandResult, SkillsIdentifier } from '@lobechat/builtin-tool-skills';
|
||||
import {
|
||||
type DeviceFileAccess,
|
||||
type ExportFileResult,
|
||||
type SkillRuntimeService,
|
||||
SkillsExecutionRuntime,
|
||||
@@ -21,6 +25,7 @@ import { MarketService } from '@/server/services/market';
|
||||
import { SkillResourceService } from '@/server/services/skill/resource';
|
||||
import { preprocessLhCommand } from '@/server/services/toolExecution/preprocessLhCommand';
|
||||
|
||||
import { deviceProxy } from '../deviceProxy';
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
const log = debug('lobe-server:skills-runtime');
|
||||
@@ -350,7 +355,9 @@ export const skillsRuntime: ServerRuntimeRegistration = {
|
||||
// the identifier prefix and the bundle → index-child content resolution
|
||||
// (also used by `aiAgent/index.ts` when building `<available_skills>`).
|
||||
// `source: 'builtin'` is the type-system carrier shape required by
|
||||
// `BuiltinSkill`; the runtime never reads `source`.
|
||||
// `BuiltinSkill`; the runtime re-tags `source: 'agent'` in the activateSkill
|
||||
// result based on the identifier prefix so the inspector can show
|
||||
// "Activate Agent Skill" + the friendly `title`.
|
||||
const agentSkillBuiltins: BuiltinSkill[] = context.agentId
|
||||
? await new AgentDocumentsService(context.serverDB, context.userId)
|
||||
.getAgentSkills(context.agentId)
|
||||
@@ -361,6 +368,7 @@ export const skillsRuntime: ServerRuntimeRegistration = {
|
||||
identifier: skill.identifier,
|
||||
name: skill.name,
|
||||
source: 'builtin' as const,
|
||||
...(skill.title && { title: skill.title }),
|
||||
})),
|
||||
)
|
||||
.catch((error) => {
|
||||
@@ -369,8 +377,70 @@ export const skillsRuntime: ServerRuntimeRegistration = {
|
||||
})
|
||||
: [];
|
||||
|
||||
// Project skills live on the device filesystem. Read them through the
|
||||
// device gateway by reusing the local-system tools — no special
|
||||
// file-read primitive, just the existing capabilities over deviceProxy.
|
||||
// - `readFile` loads SKILL.md and validated reference files.
|
||||
// - `globFiles` enumerates the skill directory so `readReference` can
|
||||
// reject paths the model guessed (e.g. `.env`) instead of trusting
|
||||
// the raw string. The discovery payload no longer carries the file
|
||||
// tree (see commit 8e8f3aed14), so we enumerate live at read time.
|
||||
const { activeDeviceId, projectSkills } = context;
|
||||
let deviceFileAccess: DeviceFileAccess | undefined;
|
||||
if (activeDeviceId && context.userId) {
|
||||
const userId = context.userId;
|
||||
deviceFileAccess = {
|
||||
listFiles: async (dir: string) => {
|
||||
const result = await deviceProxy.executeToolCall(
|
||||
{ deviceId: activeDeviceId, userId },
|
||||
{
|
||||
apiName: LocalSystemApiName.globFiles,
|
||||
// `**/*` matches every regular file recursively under `dir`.
|
||||
// The device-side enumerator already skips hidden files; the
|
||||
// runtime re-checks segments as defense in depth.
|
||||
arguments: JSON.stringify({ pattern: '**/*', scope: dir }),
|
||||
identifier: LocalSystemIdentifier,
|
||||
},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || result.content || `globFiles failed: ${dir}`);
|
||||
}
|
||||
let payload: { files?: unknown };
|
||||
try {
|
||||
payload = JSON.parse(result.content) as { files?: unknown };
|
||||
} catch {
|
||||
throw new Error(`globFiles returned a non-JSON payload for ${dir}`);
|
||||
}
|
||||
if (!Array.isArray(payload.files)) return [];
|
||||
// Files come back as paths relative to `scope` (POSIX). Strip any
|
||||
// absolute path the engine may have emitted so the runtime can
|
||||
// compare against normalized user-supplied relative paths.
|
||||
return payload.files
|
||||
.filter((f): f is string => typeof f === 'string')
|
||||
.map((f) => (f.startsWith(dir) ? f.slice(dir.length).replace(/^[/\\]+/, '') : f));
|
||||
},
|
||||
readFile: async (filePath: string) => {
|
||||
const result = await deviceProxy.executeToolCall(
|
||||
{ deviceId: activeDeviceId, userId },
|
||||
{
|
||||
apiName: LocalSystemApiName.readFile,
|
||||
// Read the whole file; SKILL.md and references are small.
|
||||
arguments: JSON.stringify({ loc: [0, 5000], path: filePath }),
|
||||
identifier: LocalSystemIdentifier,
|
||||
},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || result.content || `readFile failed: ${filePath}`);
|
||||
}
|
||||
return result.content;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return new SkillsExecutionRuntime({
|
||||
builtinSkills: [...filterBuiltinSkills(builtinSkills), ...agentSkillBuiltins],
|
||||
deviceFileAccess,
|
||||
projectSkills,
|
||||
service,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface ToolExecutionContext {
|
||||
messageId?: string;
|
||||
/** Agent runtime operation ID for structured tool outcome identity. */
|
||||
operationId?: string;
|
||||
/**
|
||||
* Project-level skills (name + absolute SKILL.md path) discovered on the
|
||||
* device filesystem. Used by the Skills runtime to load them on demand via
|
||||
* the device gateway. Derived from the operation's skill set.
|
||||
*/
|
||||
projectSkills?: { location: string; name: string }[];
|
||||
/** Conversation scope captured when the operation was created */
|
||||
scope?: string | null;
|
||||
/** Server database for LobeHub Skills execution */
|
||||
|
||||
@@ -40,6 +40,12 @@ export interface ExecAgentTaskParams {
|
||||
fileIds?: string[];
|
||||
/** Parent message ID for regeneration/continue (skip user message creation, branch from this message) */
|
||||
parentMessageId?: string;
|
||||
/**
|
||||
* Project-level skills discovered on the device filesystem
|
||||
* (`.agents/skills` / `.claude/skills`). Surfaced in `<available_skills>`
|
||||
* and loaded on demand via the readFile tool.
|
||||
*/
|
||||
projectSkills?: { description?: string; name: string; path: string }[];
|
||||
prompt: string;
|
||||
/** Resume a previous op paused on `human_approve_required` instead of starting from a fresh user prompt. */
|
||||
resumeApproval?: ResumeApprovalParam;
|
||||
|
||||
@@ -8,15 +8,51 @@ import type { ConversationContext, ExecAgentResult, MessageMetadata } from '@lob
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { aiAgentService, type ResumeApprovalParam } from '@/services/aiAgent';
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
import { messageService } from '@/services/message';
|
||||
import { topicService } from '@/services/topic';
|
||||
import { getAgentStoreState } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
import { consumePendingTopicRepos, getPendingTopicRepos } from '@/store/chat/pendingTopicRepos';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import type { ChatStore } from '@/store/chat/store';
|
||||
import type { StoreSetter } from '@/store/types';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
import { createGatewayEventHandler } from './gatewayEventHandler';
|
||||
|
||||
/**
|
||||
* Scan the active working directory for project-level skills
|
||||
* (`.agents/skills` / `.claude/skills`) so the server can surface them in
|
||||
* `<available_skills>`. Desktop-only and best-effort: a failed scan must not
|
||||
* block the send.
|
||||
*/
|
||||
const resolveProjectSkills = async (
|
||||
get: () => ChatStore,
|
||||
): Promise<{ description?: string; name: string; path: string }[] | undefined> => {
|
||||
if (!isDesktop) return undefined;
|
||||
|
||||
const topicWorkingDirectory = topicSelectors.currentTopicWorkingDirectory(get());
|
||||
const agentWorkingDirectory = agentSelectors.currentAgentWorkingDirectory(getAgentStoreState());
|
||||
const workingDirectory = topicWorkingDirectory ?? agentWorkingDirectory;
|
||||
if (!workingDirectory) return undefined;
|
||||
|
||||
try {
|
||||
const { skills } = await localFileService.listProjectSkills({ scope: workingDirectory });
|
||||
if (skills.length === 0) return undefined;
|
||||
// The directory tree is enumerated lazily at activation time by the Skills
|
||||
// runtime (via the local-system `listFiles` tool), so we drop `files` here
|
||||
// — keeps the op-param payload small.
|
||||
return skills.map((skill) => ({
|
||||
description: skill.description,
|
||||
name: skill.name,
|
||||
path: skill.path,
|
||||
}));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
type Setter = StoreSetter<ChatStore>;
|
||||
|
||||
// ─── Types ───
|
||||
@@ -317,6 +353,8 @@ export class GatewayActionImpl {
|
||||
? this.#get().getOperationAbortSignal(parentOperationId)
|
||||
: undefined;
|
||||
|
||||
const projectSkills = await resolveProjectSkills(this.#get);
|
||||
|
||||
const result = await aiAgentService.execAgentTask(
|
||||
{
|
||||
agentId: context.agentId,
|
||||
@@ -336,6 +374,7 @@ export class GatewayActionImpl {
|
||||
clientRuntime: isDesktop ? 'desktop' : 'web',
|
||||
fileIds,
|
||||
parentMessageId,
|
||||
projectSkills,
|
||||
prompt: message,
|
||||
resumeApproval,
|
||||
trigger: metadata?.trigger,
|
||||
|
||||
Reference in New Issue
Block a user