mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 17:02:11 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e790c4d30a | ||
|
|
bbd517e5b5 |
@@ -2,13 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Web: show published skills on user profiles (thanks @njoylab, #20).
|
||||
|
||||
### Fixed
|
||||
- Registry: drop missing skills during search hydration (thanks @aaronn, #28).
|
||||
- CLI: use path-based skill metadata lookup for updates (thanks @daveonkels, #22).
|
||||
- Search: keep highlighted-only filtering and clamp vector candidates to Convex limits (thanks @aaronn, #30).
|
||||
|
||||
## 0.3.0 - 2026-01-19
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./lib/access', () => ({
|
||||
assertRole: vi.fn(),
|
||||
requireUser: vi.fn(),
|
||||
}))
|
||||
|
||||
const { requireUser } = await import('./lib/access')
|
||||
const { __test } = await import('./comments')
|
||||
|
||||
describe('comments mutations', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(requireUser).mockReset()
|
||||
})
|
||||
|
||||
it('add updates comment count without touching updatedAt', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
_id: 'skills:1',
|
||||
stats: { comments: 2 },
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const ctx = { db: { get, insert, patch } } as never
|
||||
|
||||
await __test.addHandler(ctx, { skillId: 'skills:1', body: ' hello ' } as never)
|
||||
|
||||
expect(patch).toHaveBeenCalledTimes(1)
|
||||
expect(patch).toHaveBeenCalledWith('skills:1', {
|
||||
stats: { comments: 3 },
|
||||
})
|
||||
const skillPatch = vi.mocked(patch).mock.calls[0]?.[1] as Record<string, unknown>
|
||||
expect(skillPatch.updatedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remove updates comment count without touching updatedAt', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:2',
|
||||
user: { _id: 'users:2', role: 'moderator' },
|
||||
} as never)
|
||||
|
||||
const comment = {
|
||||
_id: 'comments:1',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
softDeletedAt: undefined,
|
||||
}
|
||||
const skill = {
|
||||
_id: 'skills:1',
|
||||
stats: { comments: 4 },
|
||||
}
|
||||
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:1') return comment
|
||||
if (id === 'skills:1') return skill
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const ctx = { db: { get, insert, patch } } as never
|
||||
|
||||
await __test.removeHandler(ctx, { commentId: 'comments:1' } as never)
|
||||
|
||||
expect(patch).toHaveBeenCalledTimes(2)
|
||||
expect(patch).toHaveBeenNthCalledWith(2, 'skills:1', {
|
||||
stats: { comments: 3 },
|
||||
})
|
||||
const skillPatch = vi.mocked(patch).mock.calls[1]?.[1] as Record<string, unknown>
|
||||
expect(skillPatch.updatedAt).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+55
-68
@@ -3,67 +3,6 @@ import type { Doc } from './_generated/dataModel'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { assertRole, requireUser } from './lib/access'
|
||||
|
||||
async function addHandler(
|
||||
ctx: import('./_generated/server').MutationCtx,
|
||||
args: { skillId: import('./_generated/dataModel').Id<'skills'>; body: string },
|
||||
) {
|
||||
const { userId } = await requireUser(ctx)
|
||||
const body = args.body.trim()
|
||||
if (!body) throw new Error('Comment body required')
|
||||
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
|
||||
await ctx.db.insert('comments', {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
body,
|
||||
createdAt: Date.now(),
|
||||
softDeletedAt: undefined,
|
||||
deletedBy: undefined,
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
|
||||
})
|
||||
}
|
||||
|
||||
async function removeHandler(
|
||||
ctx: import('./_generated/server').MutationCtx,
|
||||
args: { commentId: import('./_generated/dataModel').Id<'comments'> },
|
||||
) {
|
||||
const { user } = await requireUser(ctx)
|
||||
const comment = await ctx.db.get(args.commentId)
|
||||
if (!comment) throw new Error('Comment not found')
|
||||
if (comment.softDeletedAt) return
|
||||
|
||||
const isOwner = comment.userId === user._id
|
||||
if (!isOwner) {
|
||||
assertRole(user, ['admin', 'moderator'])
|
||||
}
|
||||
|
||||
await ctx.db.patch(comment._id, {
|
||||
softDeletedAt: Date.now(),
|
||||
deletedBy: user._id,
|
||||
})
|
||||
|
||||
const skill = await ctx.db.get(comment.skillId)
|
||||
if (skill) {
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'comment.delete',
|
||||
targetType: 'comment',
|
||||
targetId: comment._id,
|
||||
metadata: { skillId: comment.skillId },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export const listBySkill = query({
|
||||
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -86,15 +25,63 @@ export const listBySkill = query({
|
||||
|
||||
export const add = mutation({
|
||||
args: { skillId: v.id('skills'), body: v.string() },
|
||||
handler: addHandler,
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx)
|
||||
const body = args.body.trim()
|
||||
if (!body) throw new Error('Comment body required')
|
||||
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
|
||||
await ctx.db.insert('comments', {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
body,
|
||||
createdAt: Date.now(),
|
||||
softDeletedAt: undefined,
|
||||
deletedBy: undefined,
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const remove = mutation({
|
||||
args: { commentId: v.id('comments') },
|
||||
handler: removeHandler,
|
||||
})
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
const comment = await ctx.db.get(args.commentId)
|
||||
if (!comment) throw new Error('Comment not found')
|
||||
if (comment.softDeletedAt) return
|
||||
|
||||
export const __test = {
|
||||
addHandler,
|
||||
removeHandler,
|
||||
}
|
||||
const isOwner = comment.userId === user._id
|
||||
if (!isOwner) {
|
||||
assertRole(user, ['admin', 'moderator'])
|
||||
}
|
||||
|
||||
await ctx.db.patch(comment._id, {
|
||||
softDeletedAt: Date.now(),
|
||||
deletedBy: user._id,
|
||||
})
|
||||
|
||||
const skill = await ctx.db.get(comment.skillId)
|
||||
if (skill) {
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'comment.delete',
|
||||
targetType: 'comment',
|
||||
targetId: comment._id,
|
||||
metadata: { skillId: comment.skillId },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
+41
-63
@@ -1,6 +1,5 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { internalAction, internalMutation } from './_generated/server'
|
||||
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
|
||||
import { parseClawdisMetadata, parseFrontmatter } from './lib/skills'
|
||||
@@ -14,17 +13,6 @@ type SeedSkillSpec = {
|
||||
rawSkillMd: string
|
||||
}
|
||||
|
||||
type SeedActionArgs = {
|
||||
reset?: boolean
|
||||
}
|
||||
|
||||
type SeedActionResult = {
|
||||
ok: true
|
||||
results: Array<Record<string, unknown> & { slug: string }>
|
||||
}
|
||||
|
||||
type SeedMutationResult = Record<string, unknown>
|
||||
|
||||
const SEED_SKILLS: SeedSkillSpec[] = [
|
||||
{
|
||||
slug: 'padel',
|
||||
@@ -249,19 +237,53 @@ function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
|
||||
)}${rawSkillMd.slice(frontmatterEnd)}`
|
||||
}
|
||||
|
||||
async function seedNixSkillsHandler(
|
||||
ctx: ActionCtx,
|
||||
args: SeedActionArgs,
|
||||
): Promise<SeedActionResult> {
|
||||
const results: Array<Record<string, unknown> & { slug: string }> = []
|
||||
export const seedNixSkills = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const results = []
|
||||
|
||||
for (const spec of SEED_SKILLS) {
|
||||
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
|
||||
const frontmatter = parseFrontmatter(skillMd)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
|
||||
|
||||
const result = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
reset: args.reset,
|
||||
storageId,
|
||||
metadata: spec.metadata,
|
||||
frontmatter,
|
||||
clawdis,
|
||||
skillMd,
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
})
|
||||
|
||||
results.push({ slug: spec.slug, ...result })
|
||||
}
|
||||
|
||||
return { ok: true, results }
|
||||
},
|
||||
})
|
||||
|
||||
export const seedPadelSkill = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
|
||||
if (!spec) throw new Error('padel seed spec missing')
|
||||
|
||||
for (const spec of SEED_SKILLS) {
|
||||
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
|
||||
const frontmatter = parseFrontmatter(skillMd)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
|
||||
|
||||
const result: SeedMutationResult = await ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
return ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
reset: args.reset,
|
||||
storageId,
|
||||
metadata: spec.metadata,
|
||||
@@ -273,51 +295,7 @@ async function seedNixSkillsHandler(
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
})
|
||||
|
||||
results.push({ slug: spec.slug, ...result })
|
||||
}
|
||||
|
||||
return { ok: true, results }
|
||||
}
|
||||
|
||||
export const seedNixSkills: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: seedNixSkillsHandler,
|
||||
})
|
||||
|
||||
async function seedPadelSkillHandler(
|
||||
ctx: ActionCtx,
|
||||
args: SeedActionArgs,
|
||||
): Promise<SeedMutationResult> {
|
||||
const spec = SEED_SKILLS.find((entry) => entry.slug === 'padel')
|
||||
if (!spec) throw new Error('padel seed spec missing')
|
||||
|
||||
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata)
|
||||
const frontmatter = parseFrontmatter(skillMd)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
const storageId = await ctx.storage.store(new Blob([skillMd], { type: 'text/markdown' }))
|
||||
|
||||
return (await ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
reset: args.reset,
|
||||
storageId,
|
||||
metadata: spec.metadata,
|
||||
frontmatter,
|
||||
clawdis,
|
||||
skillMd,
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
})) as SeedMutationResult
|
||||
}
|
||||
|
||||
export const seedPadelSkill: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: seedPadelSkillHandler,
|
||||
})
|
||||
|
||||
export const seedSkillMutation = internalMutation({
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('./skills', () => ({
|
||||
|
||||
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
|
||||
const { publishVersionForUser } = await import('./skills')
|
||||
const { __handlers } = await import('./httpApi')
|
||||
const { __handlers, cliSkillDeleteHttp, cliSkillUndeleteHttp } = await import('./httpApi')
|
||||
const { hashSkillFiles } = await import('./lib/skills')
|
||||
|
||||
function makeCtx(partial: Record<string, unknown>) {
|
||||
@@ -33,7 +33,7 @@ describe('httpApi handlers', () => {
|
||||
expect(await response.json()).toEqual({ results: [] })
|
||||
})
|
||||
|
||||
it('searchSkillsHttp forwards args (approvedOnly alias)', async () => {
|
||||
it('searchSkillsHttp forwards args', async () => {
|
||||
const runAction = vi.fn().mockResolvedValue([
|
||||
{
|
||||
score: 1,
|
||||
@@ -48,27 +48,14 @@ describe('httpApi handlers', () => {
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
query: 'test',
|
||||
limit: 5,
|
||||
highlightedOnly: true,
|
||||
approvedOnly: true,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.results[0].slug).toBe('a')
|
||||
})
|
||||
|
||||
it('searchSkillsHttp forwards highlightedOnly', async () => {
|
||||
const runAction = vi.fn().mockResolvedValue([])
|
||||
await __handlers.searchSkillsHandler(
|
||||
makeCtx({ runAction }),
|
||||
new Request('https://example.com/api/search?q=test&highlightedOnly=true'),
|
||||
)
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
query: 'test',
|
||||
limit: undefined,
|
||||
highlightedOnly: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('searchSkillsHttp omits highlightedOnly when approvedOnly is false', async () => {
|
||||
it('searchSkillsHttp omits approvedOnly when false', async () => {
|
||||
const runAction = vi.fn().mockResolvedValue([])
|
||||
await __handlers.searchSkillsHandler(
|
||||
makeCtx({ runAction }),
|
||||
@@ -77,7 +64,7 @@ describe('httpApi handlers', () => {
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
query: 'test',
|
||||
limit: undefined,
|
||||
highlightedOnly: undefined,
|
||||
approvedOnly: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -429,14 +416,13 @@ describe('httpApi handlers', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
|
||||
const runMutation = vi.fn().mockResolvedValue({ ok: true })
|
||||
const response = await __handlers.cliSkillDeleteHandler(
|
||||
const response = await cliSkillUndeleteHttp(
|
||||
makeCtx({ runMutation }),
|
||||
new Request('https://x/api/cli/skill/undelete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: 'demo' }),
|
||||
}),
|
||||
false,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
@@ -451,14 +437,13 @@ describe('httpApi handlers', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
|
||||
const runMutation = vi.fn().mockResolvedValue({ ok: true })
|
||||
const response = await __handlers.cliSkillDeleteHandler(
|
||||
const response = await cliSkillDeleteHttp(
|
||||
makeCtx({ runMutation }),
|
||||
new Request('https://x/api/cli/skill/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: 'demo' }),
|
||||
}),
|
||||
true,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
|
||||
+1
-2
@@ -44,14 +44,13 @@ async function searchSkillsHandler(ctx: ActionCtx, request: Request) {
|
||||
const query = url.searchParams.get('q')?.trim() ?? ''
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const approvedOnly = url.searchParams.get('approvedOnly') === 'true'
|
||||
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true' || approvedOnly
|
||||
|
||||
if (!query) return json({ results: [] })
|
||||
|
||||
const results = (await ctx.runAction(api.search.searchSkills, {
|
||||
query,
|
||||
limit,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
approvedOnly: approvedOnly || undefined,
|
||||
})) as SearchSkillEntry[]
|
||||
|
||||
return json({
|
||||
|
||||
+4
-7
@@ -44,9 +44,6 @@ type ListSkillsResult = {
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
type SkillFile = Doc<'skillVersions'>['files'][number]
|
||||
type SoulFile = Doc<'soulVersions'>['files'][number]
|
||||
|
||||
type GetBySlugResult = {
|
||||
skill: {
|
||||
_id: Id<'skills'>
|
||||
@@ -321,7 +318,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource ?? null,
|
||||
files: version.files.map((file: SkillFile) => ({
|
||||
files: version.files.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
@@ -787,8 +784,8 @@ function parseListSort(value: string | null): SkillListSort {
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const data = new Uint8Array(bytes)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer)
|
||||
return toHex(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
@@ -928,7 +925,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource ?? null,
|
||||
files: version.files.map((file: SoulFile) => ({
|
||||
files: version.files.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
|
||||
@@ -13,26 +13,12 @@ describe('searchText', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('matchesExactTokens requires at least one query token to prefix-match', () => {
|
||||
it('matchesExactTokens requires all query tokens', () => {
|
||||
const queryTokens = tokenize('Remind Me')
|
||||
expect(matchesExactTokens(queryTokens, ['Remind Me', '/remind-me', 'Short summary'])).toBe(true)
|
||||
// "Reminder" starts with "remind", so it matches with prefix matching
|
||||
expect(matchesExactTokens(queryTokens, ['Reminder tool', '/reminder', 'Short summary'])).toBe(
|
||||
true,
|
||||
false,
|
||||
)
|
||||
// Matches because "remind" token is present
|
||||
expect(matchesExactTokens(queryTokens, ['Remind tool', '/remind', 'Short summary'])).toBe(true)
|
||||
// No matching tokens at all
|
||||
expect(matchesExactTokens(queryTokens, ['Other tool', '/other', 'Short summary'])).toBe(false)
|
||||
})
|
||||
|
||||
it('matchesExactTokens supports prefix matching for partial queries', () => {
|
||||
// "go" should match "gohome" because "gohome" starts with "go"
|
||||
expect(matchesExactTokens(['go'], ['GoHome', '/gohome', 'Navigate home'])).toBe(true)
|
||||
// "pad" should match "padel"
|
||||
expect(matchesExactTokens(['pad'], ['Padel', '/padel', 'Tennis-like sport'])).toBe(true)
|
||||
// "xyz" should not match anything
|
||||
expect(matchesExactTokens(['xyz'], ['GoHome', '/gohome', 'Navigate home'])).toBe(false)
|
||||
})
|
||||
|
||||
it('matchesExactTokens ignores empty inputs', () => {
|
||||
|
||||
@@ -18,10 +18,8 @@ export function matchesExactTokens(
|
||||
if (!text) return false
|
||||
const textTokens = tokenize(text)
|
||||
if (textTokens.length === 0) return false
|
||||
// Require at least one token to prefix-match, allowing vector similarity to determine relevance
|
||||
return queryTokens.some((queryToken) =>
|
||||
textTokens.some((textToken) => textToken.includes(queryToken)),
|
||||
)
|
||||
const textSet = new Set(textTokens)
|
||||
return queryTokens.every((token) => textSet.has(token))
|
||||
}
|
||||
|
||||
export const __test = { normalize, tokenize, matchesExactTokens }
|
||||
|
||||
@@ -75,20 +75,16 @@ export async function publishVersionForUser(
|
||||
if (sanitizedFiles.some((file) => !file.path)) {
|
||||
throw new ConvexError('Invalid file paths')
|
||||
}
|
||||
const safeFiles = sanitizedFiles.map((file) => ({
|
||||
...file,
|
||||
path: file.path as string,
|
||||
}))
|
||||
if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
|
||||
if (sanitizedFiles.some((file) => !isTextFile(file.path ?? '', file.contentType ?? undefined))) {
|
||||
throw new ConvexError('Only text-based files are allowed')
|
||||
}
|
||||
|
||||
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
throw new ConvexError('Skill bundle exceeds 50MB limit')
|
||||
}
|
||||
|
||||
const readmeFile = safeFiles.find(
|
||||
const readmeFile = sanitizedFiles.find(
|
||||
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
|
||||
)
|
||||
if (!readmeFile) throw new ConvexError('SKILL.md is required')
|
||||
@@ -99,7 +95,7 @@ export async function publishVersionForUser(
|
||||
const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
|
||||
|
||||
const otherFiles = [] as Array<{ path: string; content: string }>
|
||||
for (const file of safeFiles) {
|
||||
for (const file of sanitizedFiles) {
|
||||
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
|
||||
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
|
||||
const content = await fetchText(ctx, file.storageId)
|
||||
@@ -114,7 +110,7 @@ export async function publishVersionForUser(
|
||||
})
|
||||
|
||||
const fingerprintPromise = hashSkillFiles(
|
||||
safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
|
||||
)
|
||||
|
||||
const changelogPromise =
|
||||
@@ -124,7 +120,7 @@ export async function publishVersionForUser(
|
||||
slug,
|
||||
version,
|
||||
readmeText,
|
||||
files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
|
||||
})
|
||||
|
||||
const embeddingPromise = generateEmbedding(embeddingText)
|
||||
@@ -152,9 +148,9 @@ export async function publishVersionForUser(
|
||||
version: args.forkOf.version?.trim() || undefined,
|
||||
}
|
||||
: undefined,
|
||||
files: safeFiles.map((file) => ({
|
||||
files: sanitizedFiles.map((file) => ({
|
||||
...file,
|
||||
path: file.path,
|
||||
path: file.path ?? '',
|
||||
})),
|
||||
parsed: {
|
||||
frontmatter,
|
||||
@@ -173,7 +169,7 @@ export async function publishVersionForUser(
|
||||
version,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
files: safeFiles,
|
||||
files: sanitizedFiles,
|
||||
publishedAt: Date.now(),
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
+4
-6
@@ -38,9 +38,8 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
return []
|
||||
}
|
||||
const limit = args.limit ?? 10
|
||||
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
|
||||
let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
|
||||
let candidateLimit = Math.max(limit * 3, 50)
|
||||
let hydrated: HydratedEntry[] = []
|
||||
let scoreById = new Map<Id<'skillEmbeddings'>, number>()
|
||||
let exactMatches: HydratedEntry[] = []
|
||||
@@ -150,9 +149,8 @@ export const searchSouls: ReturnType<typeof action> = action({
|
||||
return []
|
||||
}
|
||||
const limit = args.limit ?? 10
|
||||
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
|
||||
let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 1000)
|
||||
let candidateLimit = Math.max(limit * 3, 50)
|
||||
let hydrated: HydratedSoulEntry[] = []
|
||||
let scoreById = new Map<Id<'soulEmbeddings'>, number>()
|
||||
let exactMatches: HydratedSoulEntry[] = []
|
||||
|
||||
+1
-2
@@ -242,8 +242,7 @@ export const ensureSeedUserInternal = internalMutation({
|
||||
})
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const data = new Uint8Array(bytes)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
return toHex(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -165,10 +165,9 @@ export const listWithLatest = query({
|
||||
.order('desc')
|
||||
.take(takeLimit)
|
||||
} else if (args.ownerUserId) {
|
||||
const ownerUserId = args.ownerUserId
|
||||
entries = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', ownerUserId))
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
|
||||
.order('desc')
|
||||
.take(takeLimit)
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -377,7 +377,7 @@ export const insertVersion = internalMutation({
|
||||
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
|
||||
.order('desc')
|
||||
.take(2)
|
||||
let soul: Doc<'souls'> | null = soulMatches[0] ?? null
|
||||
let soul = soulMatches[0] ?? null
|
||||
|
||||
if (soul && soul.ownerUserId !== userId) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
|
||||
+57
-82
@@ -1,7 +1,6 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { internalAction, internalMutation, internalQuery } from './_generated/server'
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 200
|
||||
@@ -45,25 +44,6 @@ type BackfillState = {
|
||||
doneAt?: number
|
||||
}
|
||||
|
||||
type BackfillActionArgs = {
|
||||
batchSize?: number
|
||||
maxBatches?: number
|
||||
resetCursor?: boolean
|
||||
}
|
||||
|
||||
type BackfillStats = {
|
||||
scanned: number
|
||||
patched: number
|
||||
batches: number
|
||||
}
|
||||
|
||||
type BackfillActionResult = {
|
||||
ok: true
|
||||
isDone: boolean
|
||||
cursor: string | null
|
||||
stats: BackfillStats
|
||||
}
|
||||
|
||||
export const getSkillStatBackfillStateInternal = internalQuery({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<BackfillState> => {
|
||||
@@ -107,73 +87,68 @@ export const setSkillStatBackfillStateInternal = internalMutation({
|
||||
},
|
||||
})
|
||||
|
||||
async function runSkillStatBackfillInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: BackfillActionArgs,
|
||||
): Promise<BackfillActionResult> {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
|
||||
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
|
||||
|
||||
if (args.resetCursor) {
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: undefined,
|
||||
doneAt: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const state = (await ctx.runQuery(
|
||||
internal.statsMaintenance.getSkillStatBackfillStateInternal,
|
||||
{},
|
||||
)) as BackfillState
|
||||
if (state.doneAt && !args.resetCursor) {
|
||||
return {
|
||||
ok: true,
|
||||
isDone: true,
|
||||
cursor: null,
|
||||
stats: { scanned: 0, patched: 0, batches: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
let cursor: string | null = state.cursor ?? null
|
||||
const stats: BackfillStats = { scanned: 0, patched: 0, batches: 0 }
|
||||
|
||||
for (let i = 0; i < maxBatches; i += 1) {
|
||||
const result = (await ctx.runMutation(
|
||||
internal.statsMaintenance.backfillSkillStatFieldsInternal,
|
||||
{
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
},
|
||||
)) as { scanned: number; patched: number; cursor: string | null; isDone: boolean }
|
||||
stats.scanned += result.scanned
|
||||
stats.patched += result.patched
|
||||
stats.batches += 1
|
||||
cursor = result.cursor
|
||||
|
||||
if (result.isDone) {
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: undefined,
|
||||
doneAt: Date.now(),
|
||||
})
|
||||
return { ok: true, isDone: true, cursor: null, stats }
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: cursor ?? undefined,
|
||||
doneAt: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, isDone: false, cursor, stats }
|
||||
}
|
||||
|
||||
export const runSkillStatBackfillInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
export const runSkillStatBackfillInternal = internalAction({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
resetCursor: v.optional(v.boolean()),
|
||||
},
|
||||
handler: runSkillStatBackfillInternalHandler,
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
|
||||
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
|
||||
|
||||
if (args.resetCursor) {
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: undefined,
|
||||
doneAt: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const state = await ctx.runQuery(
|
||||
internal.statsMaintenance.getSkillStatBackfillStateInternal,
|
||||
{},
|
||||
)
|
||||
if (state.doneAt && !args.resetCursor) {
|
||||
return {
|
||||
ok: true as const,
|
||||
isDone: true,
|
||||
cursor: null,
|
||||
stats: { scanned: 0, patched: 0, batches: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
let cursor = state.cursor ?? null
|
||||
const stats = { scanned: 0, patched: 0, batches: 0 }
|
||||
|
||||
for (let i = 0; i < maxBatches; i += 1) {
|
||||
const result = await ctx.runMutation(
|
||||
internal.statsMaintenance.backfillSkillStatFieldsInternal,
|
||||
{
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
},
|
||||
)
|
||||
stats.scanned += result.scanned
|
||||
stats.patched += result.patched
|
||||
stats.batches += 1
|
||||
cursor = result.cursor
|
||||
|
||||
if (result.isDone) {
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: undefined,
|
||||
doneAt: Date.now(),
|
||||
})
|
||||
return { ok: true as const, isDone: true, cursor: null, stats }
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
|
||||
cursor: cursor ?? undefined,
|
||||
doneAt: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true as const, isDone: false, cursor, stats }
|
||||
},
|
||||
})
|
||||
|
||||
function buildSkillStatPatch(skill: Doc<'skills'>) {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiRoutes } from '../../schema/index.js'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
const mockApiRequest = vi.fn()
|
||||
const mockDownloadZip = vi.fn()
|
||||
vi.mock('../../http.js', () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
downloadZip: (...args: unknown[]) => mockDownloadZip(...args),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => 'https://clawdhub.com')
|
||||
@@ -16,51 +13,13 @@ vi.mock('../registry.js', () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}))
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
start: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: '',
|
||||
}
|
||||
const mockSpinner = { stop: vi.fn(), fail: vi.fn() }
|
||||
vi.mock('../ui.js', () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message)
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
vi.mock('../../skills.js', () => ({
|
||||
extractZipToDir: vi.fn(),
|
||||
hashSkillFiles: vi.fn(),
|
||||
listTextFiles: vi.fn(),
|
||||
readLockfile: vi.fn(),
|
||||
readSkillOrigin: vi.fn(),
|
||||
writeLockfile: vi.fn(),
|
||||
writeSkillOrigin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}))
|
||||
|
||||
const { clampLimit, cmdExplore, cmdUpdate, formatExploreLine } = await import('./skills')
|
||||
const {
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
listTextFiles,
|
||||
readLockfile,
|
||||
readSkillOrigin,
|
||||
writeLockfile,
|
||||
writeSkillOrigin,
|
||||
} = await import('../../skills.js')
|
||||
const { rm, stat } = await import('node:fs/promises')
|
||||
const { clampLimit, cmdExplore, formatExploreLine } = await import('./skills')
|
||||
|
||||
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -164,28 +123,3 @@ describe('cmdExplore', () => {
|
||||
expect(second.searchParams.get('sort')).toBe('trending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdUpdate', () => {
|
||||
it('uses path-based skill lookup when no local fingerprint is available', async () => {
|
||||
mockApiRequest.mockResolvedValue({ latestVersion: { version: '1.0.0' } })
|
||||
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]))
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { demo: { version: '0.1.0', installedAt: 123 } },
|
||||
})
|
||||
vi.mocked(writeLockfile).mockResolvedValue()
|
||||
vi.mocked(readSkillOrigin).mockResolvedValue(null)
|
||||
vi.mocked(writeSkillOrigin).mockResolvedValue()
|
||||
vi.mocked(extractZipToDir).mockResolvedValue()
|
||||
vi.mocked(listTextFiles).mockResolvedValue([])
|
||||
vi.mocked(hashSkillFiles).mockReturnValue({ fingerprint: 'hash', files: [] })
|
||||
vi.mocked(stat).mockRejectedValue(new Error('missing'))
|
||||
vi.mocked(rm).mockResolvedValue()
|
||||
|
||||
await cmdUpdate(makeOpts(), 'demo', {}, false)
|
||||
|
||||
const [, args] = mockApiRequest.mock.calls[0] ?? []
|
||||
expect(args?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent('demo')}`)
|
||||
expect(args?.url).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,7 +153,7 @@ export async function cmdUpdate(
|
||||
} else {
|
||||
const meta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
|
||||
{ method: 'GET', url: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
resolveResult = { match: null, latestVersion: meta.latestVersion ?? null }
|
||||
|
||||
@@ -9,12 +9,7 @@ import { Route } from '../routes/search'
|
||||
|
||||
describe('search route', () => {
|
||||
it('redirects to the skills index', () => {
|
||||
const route = Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: { search: { q?: string; highlighted?: boolean } }) => void
|
||||
}
|
||||
}
|
||||
const beforeLoad = route.__config.beforeLoad as (args: {
|
||||
const beforeLoad = Route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
}) => void
|
||||
let thrown: unknown
|
||||
@@ -38,12 +33,7 @@ describe('search route', () => {
|
||||
})
|
||||
|
||||
it('redirects to the skills index without query', () => {
|
||||
const route = Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: { search: { q?: string; highlighted?: boolean } }) => void
|
||||
}
|
||||
}
|
||||
const beforeLoad = route.__config.beforeLoad as (args: {
|
||||
const beforeLoad = Route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
}) => void
|
||||
let thrown: unknown
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SkillsIndex } from '../routes/skills/index'
|
||||
@@ -15,7 +14,7 @@ vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
|
||||
Link: (props: { children: unknown }) => <a href="/">{props.children}</a>,
|
||||
}))
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
|
||||
+32
-42
@@ -59,27 +59,22 @@ export default function Header() {
|
||||
</Link>
|
||||
<nav className="nav-links">
|
||||
{isSoulMode ? <a href={clawdHubUrl}>ClawdHub</a> : null}
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={isSoulMode ? '/souls' : '/skills'}
|
||||
search={
|
||||
isSoulMode
|
||||
? undefined
|
||||
: {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
{isSoulMode ? 'Souls' : 'Skills'}
|
||||
</Link>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }}>
|
||||
Upload
|
||||
</Link>
|
||||
@@ -105,27 +100,22 @@ export default function Header() {
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem asChild>
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined }}
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}}
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={isSoulMode ? '/souls' : '/skills'}
|
||||
search={
|
||||
isSoulMode
|
||||
? undefined
|
||||
: {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
{isSoulMode ? 'Souls' : 'Skills'}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }}>
|
||||
|
||||
@@ -15,8 +15,6 @@ type SkillDetailPageProps = {
|
||||
redirectToCanonical?: boolean
|
||||
}
|
||||
|
||||
type SkillFile = Doc<'skillVersions'>['files'][number]
|
||||
|
||||
export function SkillDetailPage({
|
||||
slug,
|
||||
canonicalOwner,
|
||||
@@ -124,7 +122,7 @@ export function SkillDetailPage({
|
||||
if (!readme) return null
|
||||
return stripFrontmatter(readme)
|
||||
}, [readme])
|
||||
const latestFiles: SkillFile[] = latestVersion?.files ?? []
|
||||
const latestFiles = latestVersion?.files ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestVersion) return
|
||||
|
||||
@@ -10,10 +10,7 @@ export const Route = createFileRoute('/dashboard')({
|
||||
|
||||
function Dashboard() {
|
||||
const me = useQuery(api.users.me)
|
||||
const mySkills = useQuery(
|
||||
api.skills.list,
|
||||
me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip',
|
||||
) as Doc<'skills'>[] | undefined
|
||||
const mySkills = useQuery(api.skills.list, me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip')
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
@@ -32,7 +29,7 @@ function Dashboard() {
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
My Skills
|
||||
</h1>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Link to="/upload" className="btn btn-primary">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Upload New Skill
|
||||
</Link>
|
||||
@@ -43,7 +40,7 @@ function Dashboard() {
|
||||
<Package className="dashboard-empty-icon" aria-hidden="true" />
|
||||
<h2>No skills yet</h2>
|
||||
<p>Upload your first skill to share it with the community.</p>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Link to="/upload" className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Upload a Skill
|
||||
</Link>
|
||||
|
||||
+1
-11
@@ -2,7 +2,6 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Id } from '../../convex/_generated/dataModel'
|
||||
import { gravatarUrl } from '../lib/gravatar'
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
@@ -13,16 +12,7 @@ function Settings() {
|
||||
const me = useQuery(api.users.me)
|
||||
const updateProfile = useMutation(api.users.updateProfile)
|
||||
const deleteAccount = useMutation(api.users.deleteAccount)
|
||||
const tokens = useQuery(api.tokens.listMine) as
|
||||
| Array<{
|
||||
_id: Id<'apiTokens'>
|
||||
label: string
|
||||
prefix: string
|
||||
createdAt: number
|
||||
lastUsedAt?: number
|
||||
revokedAt?: number
|
||||
}>
|
||||
| undefined
|
||||
const tokens = useQuery(api.tokens.listMine)
|
||||
const createToken = useMutation(api.tokens.create)
|
||||
const revokeToken = useMutation(api.tokens.revoke)
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
|
||||
@@ -13,10 +13,6 @@ function UserProfile() {
|
||||
const { handle } = Route.useParams()
|
||||
const me = useQuery(api.users.me)
|
||||
const user = useQuery(api.users.getByHandle, { handle }) as Doc<'users'> | null | undefined
|
||||
const publishedSkills = useQuery(
|
||||
api.skills.list,
|
||||
user ? { ownerUserId: user._id, limit: 50 } : 'skip',
|
||||
) as Doc<'skills'>[] | undefined
|
||||
const starredSkills = useQuery(
|
||||
api.stars.listByUser,
|
||||
user ? { userId: user._id, limit: 50 } : 'skip',
|
||||
@@ -58,8 +54,6 @@ function UserProfile() {
|
||||
const initial = displayName.charAt(0).toUpperCase()
|
||||
const isLoadingSkills = starredSkills === undefined
|
||||
const skills = starredSkills ?? []
|
||||
const isLoadingPublished = publishedSkills === undefined
|
||||
const published = publishedSkills ?? []
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
@@ -104,34 +98,6 @@ function UserProfile() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="section-title" style={{ fontSize: '1.3rem' }}>
|
||||
Published
|
||||
</h2>
|
||||
<p className="section-subtitle">Skills published by this user.</p>
|
||||
|
||||
{isLoadingPublished ? (
|
||||
<div className="card">
|
||||
<div className="loading-indicator">Loading published skills…</div>
|
||||
</div>
|
||||
) : published.length > 0 ? (
|
||||
<div className="grid" style={{ marginBottom: 18 }}>
|
||||
{published.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h2 className="section-title" style={{ fontSize: '1.3rem' }}>
|
||||
Stars
|
||||
</h2>
|
||||
|
||||
Reference in New Issue
Block a user