mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
1 Commits
fix/github
...
feat/syste
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f14e00fcb6 |
@@ -53,6 +53,8 @@ export const SkillsPage: FC = () => {
|
||||
const [editingSkill, setEditingSkill] = useState<SkillDetail | null>(null)
|
||||
const [skillToDelete, setSkillToDelete] = useState<SkillMeta | null>(null)
|
||||
|
||||
const userSkills = skills.filter((s) => s.source !== 'system')
|
||||
const systemSkills = skills.filter((s) => s.source === 'system')
|
||||
const enabledCount = skills.filter((skill) => skill.enabled).length
|
||||
|
||||
const handleCreate = () => {
|
||||
@@ -108,16 +110,30 @@ export const SkillsPage: FC = () => {
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && skills.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onEdit={() => handleEdit(skill)}
|
||||
onDelete={() => setSkillToDelete(skill)}
|
||||
onToggle={(enabled) => handleToggle(skill, enabled)}
|
||||
<div className="space-y-8">
|
||||
<SkillsSection
|
||||
title="My Skills"
|
||||
subtitle="Custom skills you've created"
|
||||
skills={userSkills}
|
||||
showDelete
|
||||
onEdit={handleEdit}
|
||||
onDelete={setSkillToDelete}
|
||||
onToggle={handleToggle}
|
||||
emptyMessage={
|
||||
'No custom skills yet. Click "New Skill" to create one.'
|
||||
}
|
||||
/>
|
||||
|
||||
{systemSkills.length > 0 ? (
|
||||
<SkillsSection
|
||||
title="System Skills"
|
||||
subtitle="Built-in skills provided by BrowserOS"
|
||||
skills={systemSkills}
|
||||
onEdit={handleEdit}
|
||||
onDelete={setSkillToDelete}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -251,12 +267,58 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
|
||||
</Card>
|
||||
)
|
||||
|
||||
const SkillsSection: FC<{
|
||||
title: string
|
||||
subtitle: string
|
||||
skills: SkillMeta[]
|
||||
showDelete?: boolean
|
||||
onEdit: (skill: SkillMeta) => void
|
||||
onDelete: (skill: SkillMeta) => void
|
||||
onToggle: (skill: SkillMeta, enabled: boolean) => void
|
||||
emptyMessage?: string
|
||||
}> = ({
|
||||
title,
|
||||
subtitle,
|
||||
skills,
|
||||
showDelete,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
emptyMessage,
|
||||
}) => (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-sm">{title}</h2>
|
||||
<p className="text-muted-foreground text-xs">{subtitle}</p>
|
||||
</div>
|
||||
{skills.length === 0 && emptyMessage ? (
|
||||
<p className="py-4 text-center text-muted-foreground text-sm">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
showDelete={showDelete}
|
||||
onEdit={() => onEdit(skill)}
|
||||
onDelete={() => onDelete(skill)}
|
||||
onToggle={(enabled) => onToggle(skill, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const SkillCard: FC<{
|
||||
skill: SkillMeta
|
||||
showDelete?: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggle: (enabled: boolean) => void
|
||||
}> = ({ skill, onEdit, onDelete, onToggle }) => (
|
||||
}> = ({ skill, showDelete, onEdit, onDelete, onToggle }) => (
|
||||
<Card className="h-full py-0 shadow-sm">
|
||||
<CardContent className="flex h-full flex-col p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -284,15 +346,17 @@ const SkillCard: FC<{
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
{showDelete ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export type SkillSource = 'system' | 'user'
|
||||
|
||||
export type SkillMeta = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
enabled: boolean
|
||||
source: SkillSource
|
||||
}
|
||||
|
||||
export type SkillDetail = SkillMeta & {
|
||||
|
||||
@@ -58,7 +58,11 @@ export function createSkillsRoutes() {
|
||||
return c.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to delete'
|
||||
const status = msg.includes('not found') ? 404 : 500
|
||||
const status = msg.includes('not found')
|
||||
? 404
|
||||
: msg.includes('Cannot delete system')
|
||||
? 403
|
||||
: 500
|
||||
return c.json({ error: msg }, status)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
import { logger } from '../lib/logger'
|
||||
import type { SkillFrontmatter, SkillMeta } from './types'
|
||||
import type { SkillFrontmatter, SkillMeta, SkillSource } from './types'
|
||||
|
||||
async function isDirectory(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -41,6 +41,7 @@ async function parseSkillFile(
|
||||
}
|
||||
|
||||
const meta = data.metadata
|
||||
const source: SkillSource = meta?.source === 'system' ? 'system' : 'user'
|
||||
return {
|
||||
id: dirName,
|
||||
name: meta?.['display-name'] || data.name,
|
||||
@@ -48,6 +49,7 @@ async function parseSkillFile(
|
||||
location: skillMdPath,
|
||||
enabled: meta?.enabled !== 'false',
|
||||
version: meta?.version,
|
||||
source,
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to parse skill', {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import matter from 'gray-matter'
|
||||
import { INLINED_ENV } from '../env'
|
||||
import { getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import { safeSkillDir } from './service'
|
||||
import type { RemoteSkillCatalog, RemoteSkillEntry } from './types'
|
||||
@@ -74,6 +74,36 @@ async function getLocalVersion(skillId: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getLocalEnabledState(skillId: string): Promise<string | null> {
|
||||
try {
|
||||
const safeDir = safeSkillDir(skillId)
|
||||
const content = await readFile(join(safeDir, 'SKILL.md'), 'utf-8')
|
||||
const { data } = matter(content)
|
||||
const meta = data?.metadata as Record<string, string> | undefined
|
||||
return meta?.enabled ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureSystemSource(content: string): string {
|
||||
const parsed = matter(content)
|
||||
const data = parsed.data as Record<string, unknown>
|
||||
const meta = (data.metadata ?? {}) as Record<string, string>
|
||||
meta.source = 'system'
|
||||
data.metadata = meta
|
||||
return matter.stringify(parsed.content, data)
|
||||
}
|
||||
|
||||
function setEnabledState(content: string, enabled: string): string {
|
||||
const parsed = matter(content)
|
||||
const data = parsed.data as Record<string, unknown>
|
||||
const meta = (data.metadata ?? {}) as Record<string, string>
|
||||
meta.enabled = enabled
|
||||
data.metadata = meta
|
||||
return matter.stringify(parsed.content, data)
|
||||
}
|
||||
|
||||
export async function writeSkillFile(
|
||||
skillId: string,
|
||||
content: string,
|
||||
@@ -94,9 +124,10 @@ export async function syncRemoteSkills(): Promise<{
|
||||
for (const remoteSkill of catalog.skills) {
|
||||
try {
|
||||
const localVersion = await getLocalVersion(remoteSkill.id)
|
||||
let content = ensureSystemSource(remoteSkill.content)
|
||||
|
||||
if (!localVersion) {
|
||||
await writeSkillFile(remoteSkill.id, remoteSkill.content)
|
||||
await writeSkillFile(remoteSkill.id, content)
|
||||
result.installed++
|
||||
continue
|
||||
}
|
||||
@@ -105,7 +136,12 @@ export async function syncRemoteSkills(): Promise<{
|
||||
continue
|
||||
}
|
||||
|
||||
await writeSkillFile(remoteSkill.id, remoteSkill.content)
|
||||
const localEnabled = await getLocalEnabledState(remoteSkill.id)
|
||||
if (localEnabled === 'false') {
|
||||
content = setEnabledState(content, 'false')
|
||||
}
|
||||
|
||||
await writeSkillFile(remoteSkill.id, content)
|
||||
result.updated++
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync skill', {
|
||||
@@ -126,7 +162,8 @@ export async function seedFromRemote(): Promise<boolean> {
|
||||
|
||||
for (const skill of catalog.skills) {
|
||||
try {
|
||||
await writeSkillFile(skill.id, skill.content)
|
||||
const content = ensureSystemSource(skill.content)
|
||||
await writeSkillFile(skill.id, content)
|
||||
seeded++
|
||||
} catch (err) {
|
||||
logger.warn('Failed to seed remote skill', {
|
||||
@@ -137,7 +174,9 @@ export async function seedFromRemote(): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (seeded > 0) {
|
||||
logger.info(`Seeded ${seeded}/${catalog.skills.length} skills from remote catalog`)
|
||||
logger.info(
|
||||
`Seeded ${seeded}/${catalog.skills.length} skills from remote catalog`,
|
||||
)
|
||||
}
|
||||
|
||||
return seeded === catalog.skills.length
|
||||
|
||||
@@ -3,7 +3,11 @@ import { join } from 'node:path'
|
||||
import { getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import { DEFAULT_SKILLS } from './defaults'
|
||||
import { seedFromRemote, writeSkillFile } from './remote-sync'
|
||||
import {
|
||||
ensureSystemSource,
|
||||
seedFromRemote,
|
||||
writeSkillFile,
|
||||
} from './remote-sync'
|
||||
|
||||
async function hasExistingSkills(skillsDir: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -34,7 +38,8 @@ export async function seedDefaultSkills(): Promise<void> {
|
||||
for (const skill of DEFAULT_SKILLS) {
|
||||
if (await skillExists(skillsDir, skill.id)) continue
|
||||
try {
|
||||
await writeSkillFile(skill.id, skill.content)
|
||||
const content = ensureSystemSource(skill.content)
|
||||
await writeSkillFile(skill.id, content)
|
||||
seeded++
|
||||
} catch (err) {
|
||||
logger.warn('Failed to seed skill', {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SkillDetail,
|
||||
SkillFrontmatter,
|
||||
SkillMeta,
|
||||
SkillSource,
|
||||
UpdateSkillInput,
|
||||
} from './types'
|
||||
|
||||
@@ -59,6 +60,7 @@ export async function getSkill(id: string): Promise<SkillDetail | null> {
|
||||
}
|
||||
|
||||
const meta = parsed.data.metadata
|
||||
const source: SkillSource = meta?.source === 'system' ? 'system' : 'user'
|
||||
return {
|
||||
id,
|
||||
name: meta?.['display-name'] || parsed.data.name,
|
||||
@@ -66,6 +68,7 @@ export async function getSkill(id: string): Promise<SkillDetail | null> {
|
||||
location: skillMdPath,
|
||||
enabled: meta?.enabled !== 'false',
|
||||
version: meta?.version,
|
||||
source,
|
||||
content: parsed.content.trim(),
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -106,6 +109,7 @@ export async function createSkill(input: CreateSkillInput): Promise<SkillMeta> {
|
||||
description: input.description,
|
||||
location: join(dirPath, 'SKILL.md'),
|
||||
enabled: true,
|
||||
source: 'user',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +149,8 @@ export async function updateSkill(
|
||||
|
||||
await writeFile(skillMdPath, buildSkillMd(frontmatter, content))
|
||||
|
||||
const source: SkillSource =
|
||||
existingMeta.source === 'system' ? 'system' : 'user'
|
||||
return {
|
||||
id,
|
||||
name: displayName,
|
||||
@@ -152,13 +158,23 @@ export async function updateSkill(
|
||||
location: skillMdPath,
|
||||
enabled,
|
||||
version: existingMeta.version,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSkill(id: string): Promise<void> {
|
||||
const dirPath = safeSkillDir(id)
|
||||
if (!(await fileExists(join(dirPath, 'SKILL.md')))) {
|
||||
const skillMdPath = join(dirPath, 'SKILL.md')
|
||||
if (!(await fileExists(skillMdPath))) {
|
||||
throw new Error(`Skill "${id}" not found`)
|
||||
}
|
||||
|
||||
const raw = await readFile(skillMdPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
const meta = parsed.data?.metadata as Record<string, string> | undefined
|
||||
if (meta?.source === 'system') {
|
||||
throw new Error(`Cannot delete system skill "${id}"`)
|
||||
}
|
||||
|
||||
await rm(dirPath, { recursive: true })
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export type SkillFrontmatter = {
|
||||
'allowed-tools'?: string
|
||||
}
|
||||
|
||||
export type SkillSource = 'system' | 'user'
|
||||
|
||||
export type SkillMeta = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -23,6 +25,7 @@ export type SkillMeta = {
|
||||
location: string
|
||||
enabled: boolean
|
||||
version?: string
|
||||
source: SkillSource
|
||||
}
|
||||
|
||||
export type SkillDetail = SkillMeta & {
|
||||
@@ -49,4 +52,3 @@ export type RemoteSkillCatalog = {
|
||||
version: number
|
||||
skills: RemoteSkillEntry[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user