mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(agent): list project skills over device RPC in the sidebar (#15566)
* ✨ feat(agent): list project skills over device RPC in the sidebar The right-sidebar 技能 (project skills) tab only read skills over local Electron IPC, so in device mode (working dir on a bound remote device, or the web client) the list was always empty — unlike the Files / Review tabs which already branch on `deviceId`. Add a `listProjectSkills` device RPC mirroring `getProjectFileIndex`: - types: `DeviceProjectSkillItem` / `DeviceListProjectSkillsResult` - `deviceGateway.listProjectSkills` via the generic `invokeRpc` relay - TRPC `device.listProjectSkills` + `GatewayConnectionCtr` dispatch to `WorkspaceCtr.listProjectSkills` - renderer chokepoint `projectSkillService` branches on `deviceId` - `useProjectSkills(dir, deviceId?)`; remote mode lists but doesn't open previews (parity with the Files tab) - thread `remoteDeviceId` through `SkillsGroup` No device-gateway repo change needed — the RPC relay is method-agnostic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ✨ feat(agent): list project skills over device RPC for homogeneous agents too Thread `deviceId` through the homogeneous resources path (`AgentDocumentsGroup` → `ProjectLevelSkills`) so a device-bound homogeneous agent's 技能 tab populates over RPC, matching the heterogeneous `SkillsGroup`. `useProjectSkills` already accepts `deviceId`; this just wires it in and OR-s `deviceId` into the `showProjectSkills` gate. (The large AgentDocumentsGroup diff is prettier re-indentation from wrapping the outer memo() once the param list crossed the print width.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 🐛 fix(agent): resolve per-device cwd in ResourcesSection so device-mode skills load ResourcesSection computed its working directory with the legacy `topicCwd || agentCwd` selector, which misses `workingDirByDevice[deviceId]` and `device.defaultCwd`. For a device-bound agent the cwd lives in that per-device map, so it resolved to `undefined` — the project-skills SWR key was null and the fetch never fired even though `deviceId` was set (the 技能 tab showed "暂无可用技能"). Switch to `useEffectiveWorkingDirectory`, the same resolver the runtime bar / WorkingSidebar use. Fixes both the hetero SkillsGroup and the homogeneous AgentDocumentsGroup paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 💄 feat(agent): show loading state for project skills while switching path On a working-directory switch the project-skills SWR key changes, so items go empty while the new scan is in flight. The homogeneous skills panel was flashing the empty placeholder instead of a loader. Surface `useProjectSkills().isLoading` and render NeuralNetworkLoading when project skills are the only source and still loading. (The hetero SkillsGroup already shows it via SkillSection's isLoading.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import type {
|
||||
InitWorkspaceParams,
|
||||
KillCommandParams,
|
||||
ListLocalFileParams,
|
||||
ListProjectSkillsParams,
|
||||
LocalReadFileParams,
|
||||
LocalReadFilesParams,
|
||||
LocalSearchFilesParams,
|
||||
@@ -407,6 +408,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
|
||||
}
|
||||
|
||||
case 'listProjectSkills': {
|
||||
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
|
||||
}
|
||||
|
||||
case 'getGitBranchDiff': {
|
||||
return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string });
|
||||
}
|
||||
|
||||
@@ -303,3 +303,31 @@ export interface DeviceProjectFileIndexResult {
|
||||
source: 'git' | 'glob';
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single project skill (`.agents/skills` / `.claude/skills`) discovered on a
|
||||
* remote device, returned by the `listProjectSkills` device RPC. Mirrors the
|
||||
* desktop `ProjectSkillItem` (`@lobechat/electron-client-ipc`).
|
||||
*/
|
||||
export interface DeviceProjectSkillItem {
|
||||
description?: string;
|
||||
fileCount: number;
|
||||
files: string[];
|
||||
name: string;
|
||||
/** Absolute path to the SKILL.md file on the device. */
|
||||
path: string;
|
||||
/** Directory containing the SKILL.md. */
|
||||
skillDir: string;
|
||||
source: '.agents/skills' | '.claude/skills';
|
||||
}
|
||||
|
||||
/**
|
||||
* Project skills listing for a directory on a remote device, returned by the
|
||||
* `listProjectSkills` device RPC. Powers the Resources tab's skills group in
|
||||
* device mode. Mirrors the desktop `ListProjectSkillsResult`.
|
||||
*/
|
||||
export interface DeviceListProjectSkillsResult {
|
||||
root: string;
|
||||
skills: DeviceProjectSkillItem[];
|
||||
source: DeviceProjectSkillItem['source'] | null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'pathe';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
import { projectSkillService } from '@/services/projectSkill';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import type { SkillListItem } from './SkillsList';
|
||||
@@ -21,15 +21,24 @@ export interface UseProjectSkillsResult {
|
||||
* `.agents/skills/` / `.claude/skills/` in `workingDirectory`. Powers both
|
||||
* the hetero `SkillsGroup` and the homogeneous `ProjectLevelSkills` section.
|
||||
*
|
||||
* Pass `undefined` to keep the hook inert (no fetch fires) — useful when the
|
||||
* caller hasn't decided whether to render the section yet.
|
||||
* `deviceId` picks the transport: when set, the scan runs on that remote device
|
||||
* via the `device.listProjectSkills` RPC; otherwise it goes through local
|
||||
* Electron IPC. Like the Files tab, remote mode lists skills but does not open
|
||||
* previews (the device's filesystem isn't reachable by the local viewer).
|
||||
*
|
||||
* Pass `undefined` workingDirectory to keep the hook inert (no fetch fires) —
|
||||
* useful when the caller hasn't decided whether to render the section yet.
|
||||
*/
|
||||
export const useProjectSkills = (workingDirectory: string | undefined): UseProjectSkillsResult => {
|
||||
export const useProjectSkills = (
|
||||
workingDirectory: string | undefined,
|
||||
deviceId?: string,
|
||||
): UseProjectSkillsResult => {
|
||||
const openLocalFile = useChatStore((s) => s.openLocalFile);
|
||||
const isRemote = !!deviceId;
|
||||
|
||||
const { data, isLoading } = useClientDataSWR<ListProjectSkillsResult>(
|
||||
workingDirectory ? ['project-skills', workingDirectory] : null,
|
||||
() => localFileService.listProjectSkills({ scope: workingDirectory! }),
|
||||
const { data, isLoading } = useClientDataSWR<ListProjectSkillsResult | undefined>(
|
||||
workingDirectory ? ['project-skills', deviceId ?? 'local', workingDirectory] : null,
|
||||
() => projectSkillService.listProjectSkills({ deviceId, scope: workingDirectory! }),
|
||||
{ revalidateOnFocus: false, shouldRetryOnError: false },
|
||||
);
|
||||
|
||||
@@ -58,6 +67,9 @@ export const useProjectSkills = (workingDirectory: string | undefined): UseProje
|
||||
}, [data?.skills]);
|
||||
|
||||
const onOpenFile = (item: SkillListItem, relativePath: string) => {
|
||||
// A remote device has no filesystem the local viewer can open (matches the
|
||||
// Files tab); device mode lists skills but does not preview them.
|
||||
if (isRemote) return;
|
||||
const skill = skillByDir.get(item.id);
|
||||
if (!skill) return;
|
||||
openLocalFile({
|
||||
@@ -67,6 +79,7 @@ export const useProjectSkills = (workingDirectory: string | undefined): UseProje
|
||||
};
|
||||
|
||||
const onOpenSkill = (item: SkillListItem) => {
|
||||
if (isRemote) return;
|
||||
const skill = skillByDir.get(item.id);
|
||||
if (!skill) return;
|
||||
openLocalFile({ filePath: skill.path, workingDirectory: previewRoot });
|
||||
|
||||
+204
-184
@@ -252,214 +252,234 @@ const buildSkillBundleViews = (data: AgentDocumentListItem[]): SkillBundleView[]
|
||||
};
|
||||
|
||||
interface AgentDocumentsGroupProps {
|
||||
/** Bound remote device id (device mode); skills are then scanned over RPC. */
|
||||
deviceId?: string;
|
||||
style?: CSSProperties;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ style, workingDirectory }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const isLocalEnabled = useAgentStore((s) =>
|
||||
agentId ? chatConfigByIdSelectors.isLocalSystemEnabledById(agentId)(s) : false,
|
||||
);
|
||||
const openDocument = useChatStore((s) => s.openDocument);
|
||||
const [filter, setFilter] = useState<ResourceFilter>('skills');
|
||||
|
||||
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,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useClientDataSWR(agentId ? agentDocumentSWRKeys.documentsList(agentId) : null, () =>
|
||||
agentDocumentService.getDocuments({ agentId: agentId! }),
|
||||
);
|
||||
|
||||
const webData = useMemo(
|
||||
() => data.filter((doc) => doc.category === AGENT_DOCUMENT_WEB_CATEGORY),
|
||||
[data],
|
||||
);
|
||||
|
||||
const documentsData = useMemo(
|
||||
() => data.filter((doc) => doc.category === AGENT_DOCUMENT_CATEGORY),
|
||||
[data],
|
||||
);
|
||||
|
||||
const skillBundleViews = useMemo(() => buildSkillBundleViews(data), [data]);
|
||||
|
||||
const skillItems = useMemo<SkillListItem[]>(
|
||||
() =>
|
||||
skillBundleViews.map(({ bundle, files }) => ({
|
||||
description: bundle.description ?? undefined,
|
||||
fileCount: files.length,
|
||||
files,
|
||||
id: bundle.documentId,
|
||||
name: bundle.title || bundle.filename || '',
|
||||
})),
|
||||
[skillBundleViews],
|
||||
);
|
||||
|
||||
if (!agentId) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center flex={1} paddingBlock={24}>
|
||||
<NeuralNetworkLoading size={32} />
|
||||
</Center>
|
||||
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(
|
||||
({ deviceId, style, workingDirectory }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const isLocalEnabled = useAgentStore((s) =>
|
||||
agentId ? chatConfigByIdSelectors.isLocalSystemEnabledById(agentId)(s) : false,
|
||||
);
|
||||
}
|
||||
const openDocument = useChatStore((s) => s.openDocument);
|
||||
const [filter, setFilter] = useState<ResourceFilter>('skills');
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center flex={1} paddingBlock={24}>
|
||||
<Text type={'danger'}>{t('workingPanel.resources.error')}</Text>
|
||||
</Center>
|
||||
// Local desktop reads skills over IPC; a bound device reads over RPC.
|
||||
const showProjectSkills = (isLocalEnabled || !!deviceId) && !!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, isLoading: isProjectSkillsLoading } = useProjectSkills(
|
||||
showProjectSkills ? workingDirectory : undefined,
|
||||
deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
const renderAgentSkillsList = () => (
|
||||
<SkillsList
|
||||
items={skillItems}
|
||||
onOpenFile={(item, relativePath) => {
|
||||
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
|
||||
const docId = view?.pathToDocumentId.get(relativePath);
|
||||
if (!docId) return;
|
||||
const row = data.find((d) => d.documentId === docId);
|
||||
openDocument(docId, row?.id);
|
||||
}}
|
||||
onOpenSkill={(item) => {
|
||||
// Open the SKILL.md (skills/index child) when present; fall back to
|
||||
// the bundle itself (orphan bundles surface for recovery).
|
||||
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
|
||||
const indexChild = data.find((doc) => doc.parentId === item.id && doc.isSkillIndex);
|
||||
const targetDocId = indexChild?.documentId ?? view?.bundle.documentId ?? item.id;
|
||||
const targetRow = data.find((d) => d.documentId === targetDocId);
|
||||
openDocument(targetDocId, targetRow?.id);
|
||||
}}
|
||||
onSkillDragStart={(item, event) => {
|
||||
// The runtime resolves these via the `agent-skills:<filename>`
|
||||
// identifier (built from the shared const helper so the prefix stays
|
||||
// in lockstep with the server-side resolver). Display label keeps
|
||||
// the human-readable title.
|
||||
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
|
||||
const filename = view?.bundle.filename;
|
||||
if (!filename) return;
|
||||
startSkillDrag(event, {
|
||||
category: 'agentSkill',
|
||||
label: item.name,
|
||||
type: buildAgentSkillIdentifier(filename),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const {
|
||||
data = [],
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useClientDataSWR(agentId ? agentDocumentSWRKeys.documentsList(agentId) : null, () =>
|
||||
agentDocumentService.getDocuments({ agentId: agentId! }),
|
||||
);
|
||||
|
||||
const renderSkills = () => {
|
||||
// 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);
|
||||
const webData = useMemo(
|
||||
() => data.filter((doc) => doc.category === AGENT_DOCUMENT_WEB_CATEGORY),
|
||||
[data],
|
||||
);
|
||||
|
||||
if (activeCount === 0) {
|
||||
const documentsData = useMemo(
|
||||
() => data.filter((doc) => doc.category === AGENT_DOCUMENT_CATEGORY),
|
||||
[data],
|
||||
);
|
||||
|
||||
const skillBundleViews = useMemo(() => buildSkillBundleViews(data), [data]);
|
||||
|
||||
const skillItems = useMemo<SkillListItem[]>(
|
||||
() =>
|
||||
skillBundleViews.map(({ bundle, files }) => ({
|
||||
description: bundle.description ?? undefined,
|
||||
fileCount: files.length,
|
||||
files,
|
||||
id: bundle.documentId,
|
||||
name: bundle.title || bundle.filename || '',
|
||||
})),
|
||||
[skillBundleViews],
|
||||
);
|
||||
|
||||
if (!agentId) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center flex={1} gap={8} paddingBlock={24}>
|
||||
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
|
||||
<Center flex={1} paddingBlock={24}>
|
||||
<NeuralNetworkLoading size={32} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const flat = activeCount === 1;
|
||||
if (error) {
|
||||
return (
|
||||
<Center flex={1} paddingBlock={24}>
|
||||
<Text type={'danger'}>{t('workingPanel.resources.error')}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDocuments = () => (
|
||||
// Always render the tree for the Documents tab even when empty, so the
|
||||
// toolbar (new folder / new doc) stays reachable.
|
||||
<Flexbox flex={1} style={{ minHeight: 0 }}>
|
||||
<DocumentExplorerTree
|
||||
agentId={agentId}
|
||||
data={documentsData}
|
||||
mutate={mutate}
|
||||
style={{ height: '100%' }}
|
||||
const renderAgentSkillsList = () => (
|
||||
<SkillsList
|
||||
items={skillItems}
|
||||
onOpenFile={(item, relativePath) => {
|
||||
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
|
||||
const docId = view?.pathToDocumentId.get(relativePath);
|
||||
if (!docId) return;
|
||||
const row = data.find((d) => d.documentId === docId);
|
||||
openDocument(docId, row?.id);
|
||||
}}
|
||||
onOpenSkill={(item) => {
|
||||
// Open the SKILL.md (skills/index child) when present; fall back to
|
||||
// the bundle itself (orphan bundles surface for recovery).
|
||||
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
|
||||
const indexChild = data.find((doc) => doc.parentId === item.id && doc.isSkillIndex);
|
||||
const targetDocId = indexChild?.documentId ?? view?.bundle.documentId ?? item.id;
|
||||
const targetRow = data.find((d) => d.documentId === targetDocId);
|
||||
openDocument(targetDocId, targetRow?.id);
|
||||
}}
|
||||
onSkillDragStart={(item, event) => {
|
||||
// The runtime resolves these via the `agent-skills:<filename>`
|
||||
// identifier (built from the shared const helper so the prefix stays
|
||||
// in lockstep with the server-side resolver). Display label keeps
|
||||
// the human-readable title.
|
||||
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
|
||||
const filename = view?.bundle.filename;
|
||||
if (!filename) return;
|
||||
startSkillDrag(event, {
|
||||
category: 'agentSkill',
|
||||
label: item.name,
|
||||
type: buildAgentSkillIdentifier(filename),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
);
|
||||
|
||||
const renderSkills = () => {
|
||||
// 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) {
|
||||
// Project skills refetch on a working-directory switch (new SWR key →
|
||||
// empty items while in flight). Show the loader instead of flashing the
|
||||
// empty placeholder when there's nothing else to render yet.
|
||||
if (showProjectSkills && isProjectSkillsLoading) {
|
||||
return (
|
||||
<Center flex={1} paddingBlock={24}>
|
||||
<NeuralNetworkLoading size={32} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Center flex={1} gap={8} paddingBlock={24}>
|
||||
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const flat = activeCount === 1;
|
||||
|
||||
const renderWeb = () => {
|
||||
if (webData.length === 0) {
|
||||
return (
|
||||
<Center flex={1} gap={8} paddingBlock={24}>
|
||||
<Empty description={t('workingPanel.resources.empty')} icon={GlobeIcon} />
|
||||
</Center>
|
||||
<Flexbox gap={16} style={{ paddingBottom: 16 }}>
|
||||
{hasAgent &&
|
||||
(flat ? (
|
||||
renderAgentSkillsList()
|
||||
) : (
|
||||
<SkillSection
|
||||
sectionHeader={{
|
||||
count: skillItems.length,
|
||||
title: t('workingPanel.skills.section.agent'),
|
||||
}}
|
||||
>
|
||||
{renderAgentSkillsList()}
|
||||
</SkillSection>
|
||||
))}
|
||||
{hasProject && (
|
||||
<ProjectLevelSkills
|
||||
deviceId={deviceId}
|
||||
hideHeader={flat}
|
||||
workingDirectory={workingDirectory!}
|
||||
/>
|
||||
)}
|
||||
{hasUser && <UserLevelSkills hideHeader={flat} />}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
{webData.map((doc) => (
|
||||
<DocumentItem agentId={agentId} document={doc} key={doc.id} mutate={mutate} />
|
||||
))}
|
||||
};
|
||||
|
||||
const renderDocuments = () => (
|
||||
// Always render the tree for the Documents tab even when empty, so the
|
||||
// toolbar (new folder / new doc) stays reachable.
|
||||
<Flexbox flex={1} style={{ minHeight: 0 }}>
|
||||
<DocumentExplorerTree
|
||||
agentId={agentId}
|
||||
data={documentsData}
|
||||
mutate={mutate}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={12} style={style}>
|
||||
<Flexbox horizontal gap={4} role={'tablist'}>
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const active = filter === option.value;
|
||||
return (
|
||||
<div
|
||||
aria-selected={active}
|
||||
className={cx(styles.pillTab, active && styles.pillActive)}
|
||||
key={option.value}
|
||||
role={'tab'}
|
||||
onClick={() => setFilter(option.value)}
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
const renderWeb = () => {
|
||||
if (webData.length === 0) {
|
||||
return (
|
||||
<Center flex={1} gap={8} paddingBlock={24}>
|
||||
<Empty description={t('workingPanel.resources.empty')} icon={GlobeIcon} />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
{webData.map((doc) => (
|
||||
<DocumentItem agentId={agentId} document={doc} key={doc.id} mutate={mutate} />
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={12} style={style}>
|
||||
<Flexbox horizontal gap={4} role={'tablist'}>
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const active = filter === option.value;
|
||||
return (
|
||||
<div
|
||||
aria-selected={active}
|
||||
className={cx(styles.pillTab, active && styles.pillActive)}
|
||||
key={option.value}
|
||||
role={'tab'}
|
||||
onClick={() => setFilter(option.value)}
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
{filter === 'skills' && renderSkills()}
|
||||
{filter === 'documents' && renderDocuments()}
|
||||
{filter === 'web' && renderWeb()}
|
||||
</Flexbox>
|
||||
{filter === 'skills' && renderSkills()}
|
||||
{filter === 'documents' && renderDocuments()}
|
||||
{filter === 'web' && renderWeb()}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AgentDocumentsGroup.displayName = 'AgentDocumentsGroup';
|
||||
|
||||
|
||||
+36
-32
@@ -5,6 +5,8 @@ import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skill
|
||||
import { SkillSection, SkillsList, useProjectSkills } from '@/features/SkillsList';
|
||||
|
||||
interface ProjectLevelSkillsProps {
|
||||
/** Bound remote device id; when set, skills are scanned over RPC. */
|
||||
deviceId?: string;
|
||||
/**
|
||||
* Skip the `SkillSection` wrapper (no header row). Set when the parent has
|
||||
* collapsed to a single visible source and wants the list rendered flat.
|
||||
@@ -13,42 +15,44 @@ interface ProjectLevelSkillsProps {
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const ProjectLevelSkills = memo<ProjectLevelSkillsProps>(({ hideHeader, workingDirectory }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory);
|
||||
const ProjectLevelSkills = memo<ProjectLevelSkillsProps>(
|
||||
({ deviceId, hideHeader, workingDirectory }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory, deviceId);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
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;
|
||||
if (hideHeader) return list;
|
||||
|
||||
return (
|
||||
<SkillSection
|
||||
sectionHeader={{
|
||||
count: items.length,
|
||||
title: t('workingPanel.skills.section.project'),
|
||||
}}
|
||||
>
|
||||
{list}
|
||||
</SkillSection>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<SkillSection
|
||||
sectionHeader={{
|
||||
count: items.length,
|
||||
title: t('workingPanel.skills.section.project'),
|
||||
}}
|
||||
>
|
||||
{list}
|
||||
</SkillSection>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ProjectLevelSkills.displayName = 'ProjectLevelSkills';
|
||||
|
||||
|
||||
+7
-2
@@ -6,14 +6,19 @@ import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skill
|
||||
import { SkillSection, SkillsList, useProjectSkills } from '@/features/SkillsList';
|
||||
|
||||
interface SkillsGroupProps {
|
||||
/** Bound remote device id; when set, skills are scanned over RPC. */
|
||||
deviceId?: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const SkillsGroup = memo<SkillsGroupProps>(({ workingDirectory }) => {
|
||||
const SkillsGroup = memo<SkillsGroupProps>(({ deviceId, workingDirectory }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const enabled = isDesktop && !!workingDirectory;
|
||||
// Local desktop reads over IPC; a bound device reads over RPC. Either path
|
||||
// makes the skills list reachable even when this client isn't the desktop.
|
||||
const enabled = (isDesktop || !!deviceId) && !!workingDirectory;
|
||||
const { isLoading, items, onOpenFile, onOpenSkill } = useProjectSkills(
|
||||
enabled ? workingDirectory : undefined,
|
||||
deviceId,
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
+18
-10
@@ -1,22 +1,27 @@
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useEffectiveWorkingDirectory } from '@/hooks/useEffectiveWorkingDirectory';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors, agentSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
|
||||
import AgentDocumentsGroup from './AgentDocumentsGroup';
|
||||
import SkillsGroup from './SkillsGroup';
|
||||
|
||||
const ResourcesSection = memo(() => {
|
||||
interface ResourcesSectionProps {
|
||||
/** Bound remote device id (device mode); skills are then scanned over RPC. */
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
const ResourcesSection = memo<ResourcesSectionProps>(({ deviceId }) => {
|
||||
const isHetero = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
const agentWorkingDirectory = useAgentStore((s) =>
|
||||
activeAgentId ? agentByIdSelectors.getAgentWorkingDirectoryById(activeAgentId)(s) : undefined,
|
||||
);
|
||||
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
const workingDirectory = topicWorkingDirectory || agentWorkingDirectory;
|
||||
// Resolve the cwd the same way the runtime bar / WorkingSidebar do
|
||||
// (`useEffectiveWorkingDirectory`). The old `topicCwd || agentCwd` pattern
|
||||
// missed `workingDirByDevice[deviceId]` / `device.defaultCwd`, so a
|
||||
// device-bound agent resolved to `undefined` here and the skills fetch never
|
||||
// fired even though `deviceId` was set.
|
||||
const workingDirectory = useEffectiveWorkingDirectory(activeAgentId);
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
@@ -27,9 +32,12 @@ const ResourcesSection = memo(() => {
|
||||
paddingInline={'8px 12px'}
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
{isHetero && workingDirectory && <SkillsGroup workingDirectory={workingDirectory} />}
|
||||
{isHetero && workingDirectory && (
|
||||
<SkillsGroup deviceId={deviceId} workingDirectory={workingDirectory} />
|
||||
)}
|
||||
{!isHetero && (
|
||||
<AgentDocumentsGroup
|
||||
deviceId={deviceId}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
workingDirectory={workingDirectory}
|
||||
/>
|
||||
|
||||
@@ -200,7 +200,7 @@ const AgentWorkingSidebar = memo(() => {
|
||||
width={'100%'}
|
||||
>
|
||||
<ProgressSection />
|
||||
<ResourcesSection />
|
||||
<ResourcesSection deviceId={remoteDeviceId} />
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
@@ -270,6 +270,22 @@ export const deviceRouter = router({
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
|
||||
* remote device, via the device's `listProjectSkills` RPC. Powers the
|
||||
* Resources tab's skills group in device mode. Returns `null` when offline.
|
||||
*/
|
||||
listProjectSkills: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), scope: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.listProjectSkills({
|
||||
deviceId: input.deviceId,
|
||||
scope: input.scope,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revert a single file in a directory on a remote device, via the device's
|
||||
* `revertGitFile` RPC.
|
||||
|
||||
@@ -560,6 +560,120 @@ describe('DeviceGateway', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('listProjectSkills', () => {
|
||||
const configure = () => {
|
||||
mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com';
|
||||
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
|
||||
};
|
||||
|
||||
it('should return undefined when not configured', async () => {
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.listProjectSkills({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the device result through and invokes the rpc with scope', async () => {
|
||||
configure();
|
||||
const data = {
|
||||
root: '/proj',
|
||||
skills: [
|
||||
{
|
||||
description: 'spa',
|
||||
fileCount: 3,
|
||||
files: ['SKILL.md'],
|
||||
name: 'spa-routes',
|
||||
path: '/proj/.agents/skills/spa-routes/SKILL.md',
|
||||
skillDir: '/proj/.agents/skills/spa-routes',
|
||||
source: '.agents/skills',
|
||||
},
|
||||
],
|
||||
source: '.agents/skills',
|
||||
};
|
||||
mockClient.invokeRpc.mockResolvedValue({ data, success: true });
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.listProjectSkills({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual(data);
|
||||
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
|
||||
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
|
||||
{ method: 'listProjectSkills', params: { scope: '/proj' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when the rpc reports failure', async () => {
|
||||
configure();
|
||||
mockClient.invokeRpc.mockResolvedValue({ error: 'offline', success: false });
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.listProjectSkills({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the rpc succeeds without data', async () => {
|
||||
configure();
|
||||
mockClient.invokeRpc.mockResolvedValue({ success: true });
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.listProjectSkills({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined on exception', async () => {
|
||||
configure();
|
||||
mockClient.invokeRpc.mockRejectedValue(new Error('timeout'));
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
const result = await proxy.listProjectSkills({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards a custom timeout', async () => {
|
||||
configure();
|
||||
mockClient.invokeRpc.mockResolvedValue({
|
||||
data: { root: '/proj', skills: [], source: null },
|
||||
success: true,
|
||||
});
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
await proxy.listProjectSkills({
|
||||
deviceId: 'dev-1',
|
||||
scope: '/proj',
|
||||
timeout: 60_000,
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
|
||||
{ deviceId: 'dev-1', timeout: 60_000, userId: 'user-1' },
|
||||
{ method: 'listProjectSkills', params: { scope: '/proj' } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClient (lazy initialization)', () => {
|
||||
it('should return null when URL is missing', async () => {
|
||||
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
DeviceGitWorkingTreeFiles,
|
||||
DeviceGitWorkingTreePatches,
|
||||
DeviceGitWorkingTreeStatus,
|
||||
DeviceListProjectSkillsResult,
|
||||
DeviceProjectFileIndexResult,
|
||||
ProjectSkillMeta,
|
||||
WorkspaceInitResult,
|
||||
@@ -465,6 +466,41 @@ export class DeviceGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
|
||||
* remote device via the `listProjectSkills` device RPC — the Resources tab's
|
||||
* skills group in device mode. Mirrors `getProjectFileIndex`; returns
|
||||
* `undefined` when the gateway is unconfigured, the device is offline, or the
|
||||
* call fails so the UI degrades to "no skills".
|
||||
*/
|
||||
async listProjectSkills(params: {
|
||||
deviceId: string;
|
||||
scope: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceListProjectSkillsResult | undefined> {
|
||||
const { userId, deviceId, scope, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceListProjectSkillsResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'listProjectSkills', params: { scope } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('listProjectSkills: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('listProjectSkills: error for deviceId=%s — %O', deviceId, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List the remote branches (`refs/remotes/origin/*`) of a directory on a
|
||||
* remote device via the `listGitRemoteBranches` device RPC, so the web/remote
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ListProjectSkillsResult } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
|
||||
/**
|
||||
* Project skills chokepoint. Picks the transport per call from `deviceId`: a
|
||||
* remote / web target goes through the `device.listProjectSkills` RPC; the local
|
||||
* desktop talks to Electron over IPC. UI / store only see this service — the
|
||||
* electron-vs-lambda decision never leaks up. (Parallels `projectFileService`.)
|
||||
*/
|
||||
class ProjectSkillService {
|
||||
/** List `.agents/skills` / `.claude/skills` for a working directory. */
|
||||
async listProjectSkills({
|
||||
deviceId,
|
||||
scope,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
scope: string;
|
||||
}): Promise<ListProjectSkillsResult | undefined> {
|
||||
return deviceId
|
||||
? ((await lambdaClient.device.listProjectSkills.query({ deviceId, scope })) ?? undefined)
|
||||
: localFileService.listProjectSkills({ scope });
|
||||
}
|
||||
}
|
||||
|
||||
export const projectSkillService = new ProjectSkillService();
|
||||
Reference in New Issue
Block a user