mirror of
https://github.com/hanzoai/insights.git
synced 2026-11-02 09:24:48 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
1099 lines
48 KiB
TypeScript
1099 lines
48 KiB
TypeScript
import { useActions, useValues } from 'kea'
|
|
import { Form } from 'kea-forms'
|
|
import { combineUrl, router } from 'kea-router'
|
|
import { Fragment, Suspense, useEffect, useRef, useState } from 'react'
|
|
|
|
import {
|
|
IconChevronRight,
|
|
IconColumns,
|
|
IconDocument,
|
|
IconDownload,
|
|
IconPencil,
|
|
IconPlus,
|
|
IconTrash,
|
|
IconUpload,
|
|
IconX,
|
|
} from '@hanzo/icons'
|
|
import { Banner, Button, Select, Tag, TextArea, Link } from '@hanzo/elements'
|
|
|
|
import { AccessControlAction } from 'lib/components/AccessControlAction'
|
|
import { CodeSnippet, Language } from 'lib/components/CodeSnippet/CodeSnippet'
|
|
import { NotFound } from 'lib/components/NotFound'
|
|
import { dayjs } from 'lib/dayjs'
|
|
import { IconLink } from 'lib/elements/icons'
|
|
import { Field } from 'lib/elements/Field'
|
|
import { Input } from 'lib/elements/Input'
|
|
import { MarkdownWithMermaid } from 'lib/elements/Markdown/MarkdownWithMermaid'
|
|
import { Skeleton } from 'lib/elements/Skeleton'
|
|
import { toast } from 'lib/elements/Toast/Toast'
|
|
import { userHasAccess } from 'lib/utils/accessControlUtils'
|
|
import { copyToClipboard } from 'lib/utils/copyToClipboard'
|
|
import { lazyWithRetry } from 'lib/utils/retryImport'
|
|
import { SceneExport } from 'scenes/sceneTypes'
|
|
import { urls } from 'scenes/urls'
|
|
|
|
import { themeLogic } from '~/layout/navigation-3000/themeLogic'
|
|
import { SceneContent } from '~/layout/scenes/components/SceneContent'
|
|
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
|
|
import { ProductKey } from '~/queries/schema/schema-general'
|
|
import { AccessControlLevel, AccessControlResourceType } from '~/types'
|
|
|
|
import { MarkdownOutline } from 'products/ai_observability/frontend/components/MarkdownOutline'
|
|
import type { LLMSkillFileManifestApi, LLMSkillVersionSummaryApi } from 'products/skills/frontend/generated/api.schemas'
|
|
|
|
import type { SkillFormFileValues } from './llmSkillLogic'
|
|
import { SkillLogicProps, SkillMode, isSkill, llmSkillLogic } from './llmSkillLogic'
|
|
import { SKILL_NAME_MAX_LENGTH, SKILL_DESCRIPTION_MAX_LENGTH } from './skillConstants'
|
|
import { skillFileLogic } from './skillFileLogic'
|
|
import { collectFilesFromDrop } from './skillFileUpload'
|
|
import { openArchiveSkillDialog } from './skillSceneComponents'
|
|
|
|
const MonacoDiffEditor = lazyWithRetry(() => import('lib/components/MonacoDiffEditor'))
|
|
|
|
export const scene: SceneExport<SkillLogicProps> = {
|
|
component: LLMSkillScene,
|
|
logic: llmSkillLogic,
|
|
productKey: ProductKey.AI_OBSERVABILITY,
|
|
paramsToProps: ({ params: { name }, searchParams }) => ({
|
|
skillName: name && name !== 'new' ? name : 'new',
|
|
mode: searchParams?.edit === 'true' ? SkillMode.Edit : SkillMode.View,
|
|
selectedVersion: searchParams?.version ? Number(searchParams.version) || null : null,
|
|
}),
|
|
}
|
|
|
|
export function LLMSkillScene(): JSX.Element {
|
|
const {
|
|
shouldDisplaySkeleton,
|
|
skillLoading,
|
|
versionsLoading,
|
|
isSkillFormSubmitting,
|
|
isSkillMissing,
|
|
isNewSkill,
|
|
skillForm,
|
|
isViewMode,
|
|
skill,
|
|
isHistoricalVersion,
|
|
versions,
|
|
canLoadMoreVersions,
|
|
fileContentsLoading,
|
|
downloadingZip,
|
|
} = useValues(llmSkillLogic)
|
|
const { searchParams } = useValues(router)
|
|
|
|
const { submitSkillForm, deleteSkill, setMode, setSkillFormValues, loadMoreVersions, downloadSkill } =
|
|
useActions(llmSkillLogic)
|
|
|
|
if (isSkillMissing) {
|
|
return <NotFound object="skill" />
|
|
}
|
|
|
|
if (shouldDisplaySkeleton) {
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<Skeleton active className="h-4 w-2/5" />
|
|
<Skeleton active className="h-4 w-full" />
|
|
<Skeleton active className="h-4 w-3/5" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// A direct link with `?edit=true` shouldn't grant edit access the Edit/Use-as-latest buttons
|
|
// wouldn't otherwise give — fall back to the read-only view when the user can't actually publish.
|
|
const canEditSkill = userHasAccess(AccessControlResourceType.LlmSkill, AccessControlLevel.Editor)
|
|
const content =
|
|
isViewMode || !canEditSkill ? (
|
|
<SceneContent>
|
|
<SceneTitleSection
|
|
name={skill && 'name' in skill ? skill.name : 'Skill'}
|
|
resourceType={{ type: 'llm_skill' }}
|
|
isLoading={skillLoading}
|
|
actions={
|
|
<>
|
|
{isSkill(skill) && skill.is_latest ? (
|
|
<AccessControlAction
|
|
resourceType={AccessControlResourceType.LlmSkill}
|
|
minAccessLevel={AccessControlLevel.Editor}
|
|
>
|
|
<Button
|
|
type="primary"
|
|
icon={<IconPencil />}
|
|
onClick={() => setMode(SkillMode.Edit)}
|
|
size="small"
|
|
data-attr="llma-skill-edit-button"
|
|
>
|
|
Edit latest
|
|
</Button>
|
|
</AccessControlAction>
|
|
) : (
|
|
<AccessControlAction
|
|
resourceType={AccessControlResourceType.LlmSkill}
|
|
minAccessLevel={AccessControlLevel.Editor}
|
|
>
|
|
<Button
|
|
type="primary"
|
|
onClick={() => {
|
|
if (isSkill(skill)) {
|
|
setSkillFormValues({
|
|
name: skill.name,
|
|
description: skill.description,
|
|
body: skill.body,
|
|
license: skill.license || '',
|
|
compatibility: skill.compatibility || '',
|
|
})
|
|
setMode(SkillMode.Edit)
|
|
}
|
|
}}
|
|
size="small"
|
|
data-attr="llma-skill-use-as-latest-button"
|
|
>
|
|
Use as latest
|
|
</Button>
|
|
</AccessControlAction>
|
|
)}
|
|
|
|
{isSkill(skill) && (
|
|
<Button
|
|
type="secondary"
|
|
icon={<IconDownload />}
|
|
onClick={downloadSkill}
|
|
loading={downloadingZip}
|
|
size="small"
|
|
tooltip="Download this skill as a spec-compliant .zip (SKILL.md + bundled files)"
|
|
data-attr="llma-skill-download-zip-button"
|
|
>
|
|
Download .zip
|
|
</Button>
|
|
)}
|
|
|
|
<AccessControlAction
|
|
resourceType={AccessControlResourceType.LlmSkill}
|
|
minAccessLevel={AccessControlLevel.Editor}
|
|
>
|
|
<Button
|
|
type="secondary"
|
|
status="danger"
|
|
icon={<IconTrash />}
|
|
onClick={() => openArchiveSkillDialog(deleteSkill)}
|
|
size="small"
|
|
data-attr="llma-skill-delete-button"
|
|
>
|
|
Archive
|
|
</Button>
|
|
</AccessControlAction>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-6 2xl:flex-row">
|
|
<div className="min-w-0 flex-1">
|
|
<SkillViewDetails />
|
|
</div>
|
|
|
|
{!isNewSkill && (
|
|
<SkillVersionSidebar
|
|
skillName={isSkill(skill) ? skill.name : ''}
|
|
skill={isSkill(skill) ? skill : null}
|
|
versions={versions}
|
|
versionsLoading={versionsLoading}
|
|
canLoadMoreVersions={canLoadMoreVersions}
|
|
loadMoreVersions={loadMoreVersions}
|
|
searchParams={searchParams ?? {}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</SceneContent>
|
|
) : (
|
|
<Form id="skill-form" formKey="skillForm" logic={llmSkillLogic}>
|
|
<SceneContent>
|
|
<SceneTitleSection
|
|
name={skillForm.name || 'New skill'}
|
|
resourceType={{ type: 'llm_analytics' }}
|
|
isLoading={skillLoading}
|
|
actions={
|
|
<>
|
|
<Button
|
|
type="secondary"
|
|
onClick={() => {
|
|
if (isNewSkill) {
|
|
router.actions.push(urls.skills())
|
|
} else {
|
|
setMode(SkillMode.View)
|
|
}
|
|
}}
|
|
disabledReason={isSkillFormSubmitting ? 'Saving...' : undefined}
|
|
size="small"
|
|
data-attr="llma-skill-cancel-button"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
|
|
<AccessControlAction
|
|
resourceType={AccessControlResourceType.LlmSkill}
|
|
minAccessLevel={AccessControlLevel.Editor}
|
|
>
|
|
<Button
|
|
type="primary"
|
|
onClick={submitSkillForm}
|
|
loading={isSkillFormSubmitting}
|
|
size="small"
|
|
data-attr={isNewSkill ? 'skill-create-button' : 'skill-save-button'}
|
|
>
|
|
{isNewSkill ? 'Create skill' : 'Publish version'}
|
|
</Button>
|
|
</AccessControlAction>
|
|
|
|
{!isNewSkill && (
|
|
<AccessControlAction
|
|
resourceType={AccessControlResourceType.LlmSkill}
|
|
minAccessLevel={AccessControlLevel.Editor}
|
|
>
|
|
<Button
|
|
type="secondary"
|
|
status="danger"
|
|
icon={<IconTrash />}
|
|
onClick={() => openArchiveSkillDialog(deleteSkill)}
|
|
size="small"
|
|
data-attr="llma-skill-delete-button"
|
|
>
|
|
Archive
|
|
</Button>
|
|
</AccessControlAction>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-6 2xl:flex-row">
|
|
<div className="min-w-0 flex-1">
|
|
<SkillEditForm
|
|
isHistoricalVersion={isHistoricalVersion}
|
|
fileContentsLoading={fileContentsLoading}
|
|
/>
|
|
</div>
|
|
|
|
{!isNewSkill && (
|
|
<SkillVersionSidebar
|
|
skillName={isSkill(skill) ? skill.name : ''}
|
|
skill={isSkill(skill) ? skill : null}
|
|
versions={versions}
|
|
versionsLoading={versionsLoading}
|
|
canLoadMoreVersions={canLoadMoreVersions}
|
|
loadMoreVersions={loadMoreVersions}
|
|
searchParams={searchParams ?? {}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</SceneContent>
|
|
</Form>
|
|
)
|
|
return content
|
|
}
|
|
|
|
function SkillViewDetails(): JSX.Element {
|
|
const { skill, isOutlineExpanded, isDiffVisible, canCompareVersions, compareVersionOptions } =
|
|
useValues(llmSkillLogic)
|
|
const { searchParams } = useValues(router)
|
|
const { toggleOutlineExpanded, setCompareVersion } = useActions(llmSkillLogic)
|
|
const markdownContainerRef = useRef<HTMLDivElement | null>(null)
|
|
const selectedFilePath = typeof searchParams?.file === 'string' ? searchParams.file : null
|
|
|
|
if (!skill || !isSkill(skill)) {
|
|
return <></>
|
|
}
|
|
|
|
// The spec frontmatter, rendered as a compact monospace key/value block. The top-level spec
|
|
// fields are authoritative, so any stored metadata entry that reuses one of their names (or
|
|
// `version`, the platform-owned field shown last) is dropped — otherwise it would render a
|
|
// second, duplicate row for the same key.
|
|
const reservedFrontmatterKeys = new Set(['license', 'compatibility', 'allowed-tools', 'version'])
|
|
const frontmatterRows: [string, string][] = [
|
|
...(skill.license ? ([['license', skill.license]] as [string, string][]) : []),
|
|
...(skill.compatibility ? ([['compatibility', skill.compatibility]] as [string, string][]) : []),
|
|
...(skill.allowed_tools?.length
|
|
? ([['allowed-tools', skill.allowed_tools.join(' ')]] as [string, string][])
|
|
: []),
|
|
...Object.entries(skill.metadata ?? {})
|
|
.filter(([key]) => !reservedFrontmatterKeys.has(key))
|
|
.map(([key, value]): [string, string] => [key, String(value)]),
|
|
['version', String(skill.version)],
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Tag type="highlight" size="small">
|
|
v{skill.version}
|
|
</Tag>
|
|
{skill.is_latest ? (
|
|
<Tag type="success" size="small">
|
|
Latest
|
|
</Tag>
|
|
) : (
|
|
<Tag type="muted" size="small">
|
|
Historical
|
|
</Tag>
|
|
)}
|
|
<span className="text-secondary text-sm">
|
|
This skill has {skill.version_count} published version{skill.version_count === 1 ? '' : 's'}.
|
|
</span>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-semibold uppercase text-secondary">Name</label>
|
|
<p className="font-mono">{skill.name}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-semibold uppercase text-secondary">Description</label>
|
|
<p className="text-sm">{skill.description}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-semibold uppercase text-secondary">Frontmatter</label>
|
|
<div className="mt-1 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-0.5 font-mono text-xs">
|
|
{frontmatterRows.map(([key, value], index) => (
|
|
// Composite key: a metadata entry could share a name with a reserved
|
|
// frontmatter field (license/compatibility/allowed-tools), so `key` alone isn't unique.
|
|
<Fragment key={`${index}-${key}`}>
|
|
<span className="text-muted">{key}</span>
|
|
<span className="text-secondary whitespace-pre-wrap break-words">{value}</span>
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs font-semibold uppercase text-secondary">Skill body</label>
|
|
{canCompareVersions && (
|
|
<Button
|
|
size="xsmall"
|
|
type={isDiffVisible ? 'primary' : 'secondary'}
|
|
icon={<IconColumns />}
|
|
onClick={() => {
|
|
if (isDiffVisible) {
|
|
setCompareVersion(null)
|
|
} else {
|
|
const firstOption = compareVersionOptions[0]?.value
|
|
const defaultVersion = compareVersionOptions.some(
|
|
(o) => o.value === skill.version - 1
|
|
)
|
|
? skill.version - 1
|
|
: (firstOption ?? null)
|
|
setCompareVersion(defaultVersion)
|
|
}
|
|
}}
|
|
data-attr="llma-skill-compare-versions-button"
|
|
>
|
|
Compare versions
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{isDiffVisible ? (
|
|
<SkillDiffView />
|
|
) : (
|
|
<>
|
|
<MarkdownOutline
|
|
markdownText={skill.body}
|
|
containerRef={markdownContainerRef}
|
|
className="mt-2"
|
|
label="Skill outline"
|
|
tooltipText="Navigate the sections of this skill. Click a heading to scroll to it."
|
|
dataAttrPrefix="llma-skill"
|
|
isExpanded={isOutlineExpanded}
|
|
onToggleExpanded={toggleOutlineExpanded}
|
|
/>
|
|
<div ref={markdownContainerRef}>
|
|
<MarkdownWithMermaid
|
|
className="mt-1 rounded border bg-bg-light p-3"
|
|
generateHeadingIds
|
|
>
|
|
{skill.body}
|
|
</MarkdownWithMermaid>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{skill.files && skill.files.length > 0 && (
|
|
<div>
|
|
<label className="text-xs font-semibold uppercase text-secondary">
|
|
Bundled files ({skill.files.length})
|
|
</label>
|
|
<div className="mt-1 space-y-1">
|
|
{[...skill.files]
|
|
.sort((a, b) => a.path.localeCompare(b.path))
|
|
.map((file) => (
|
|
<SkillFileViewer
|
|
key={file.path}
|
|
skillName={skill.name}
|
|
file={file}
|
|
version={skill.is_latest ? undefined : skill.version}
|
|
autoOpen={selectedFilePath === file.path}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid max-w-3xl gap-3 text-sm text-secondary sm:grid-cols-2">
|
|
<div>Published {dayjs(skill.created_at).format('MMM D, YYYY h:mm A')}</div>
|
|
<div>First version created {dayjs(skill.first_version_created_at).format('MMM D, YYYY h:mm A')}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SkillDiffView(): JSX.Element {
|
|
const { skill, compareSkill, compareSkillLoading, compareVersion, compareVersionOptions } = useValues(llmSkillLogic)
|
|
const { setCompareVersion } = useActions(llmSkillLogic)
|
|
const { isDarkModeOn } = useValues(themeLogic)
|
|
|
|
if (!skill || !isSkill(skill)) {
|
|
return <></>
|
|
}
|
|
|
|
const currentVersion = skill.version
|
|
const original = compareSkill?.body ?? ''
|
|
const modified = skill.body
|
|
|
|
return (
|
|
<div className="mt-2 space-y-3" data-attr="llma-skill-diff-view">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-secondary">Comparing</span>
|
|
<Select
|
|
size="small"
|
|
value={compareVersion}
|
|
options={compareVersionOptions}
|
|
onChange={(value) => setCompareVersion(value)}
|
|
data-attr="llma-skill-diff-version-select"
|
|
/>
|
|
<span className="text-sm text-secondary">with v{currentVersion} (current)</span>
|
|
</div>
|
|
{compareSkillLoading ? (
|
|
<div className="space-y-2 rounded border p-4">
|
|
<Skeleton active className="h-4 w-full" />
|
|
<Skeleton active className="h-4 w-3/4" />
|
|
<Skeleton active className="h-4 w-1/2" />
|
|
</div>
|
|
) : !compareSkill ? (
|
|
<Banner type="warning">
|
|
Failed to load version for comparison. Try selecting a different version.
|
|
</Banner>
|
|
) : (
|
|
<div className="overflow-hidden rounded border">
|
|
<Suspense
|
|
fallback={
|
|
<div className="space-y-2 p-4">
|
|
<Skeleton active className="h-4 w-full" />
|
|
<Skeleton active className="h-4 w-3/4" />
|
|
</div>
|
|
}
|
|
>
|
|
<MonacoDiffEditor
|
|
original={original}
|
|
value={modified}
|
|
modified={modified}
|
|
language="markdown"
|
|
theme={isDarkModeOn ? 'vs-dark' : 'vs-light'}
|
|
options={{
|
|
readOnly: true,
|
|
renderSideBySide: true,
|
|
minimap: { enabled: false },
|
|
scrollBeyondLastLine: false,
|
|
wordWrap: 'on',
|
|
lineNumbers: 'off',
|
|
folding: false,
|
|
hideUnchangedRegions: { enabled: true },
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const LANGUAGE_BY_EXTENSION: Record<string, Language> = {
|
|
py: Language.Python,
|
|
js: Language.JavaScript,
|
|
jsx: Language.JavaScript,
|
|
mjs: Language.JavaScript,
|
|
ts: Language.TypeScript,
|
|
tsx: Language.TypeScript,
|
|
sh: Language.Bash,
|
|
bash: Language.Bash,
|
|
zsh: Language.Bash,
|
|
json: Language.JSON,
|
|
yaml: Language.YAML,
|
|
yml: Language.YAML,
|
|
html: Language.HTML,
|
|
xml: Language.XML,
|
|
sql: Language.SQL,
|
|
rb: Language.Ruby,
|
|
go: Language.Go,
|
|
java: Language.Java,
|
|
kt: Language.Kotlin,
|
|
swift: Language.Swift,
|
|
php: Language.PHP,
|
|
dart: Language.Dart,
|
|
cs: Language.CSharp,
|
|
m: Language.ObjectiveC,
|
|
ex: Language.Elixir,
|
|
exs: Language.Elixir,
|
|
hcl: Language.HCL,
|
|
tf: Language.HCL,
|
|
groovy: Language.Groovy,
|
|
}
|
|
|
|
function getFileLanguage(path: string, contentType?: string): Language | null {
|
|
const templateStripped = path.replace(/\.(template|tmpl|j2)$/i, '')
|
|
const ext = templateStripped.toLowerCase().split('.').pop() ?? ''
|
|
if (ext in LANGUAGE_BY_EXTENSION) {
|
|
return LANGUAGE_BY_EXTENSION[ext]
|
|
}
|
|
if (contentType?.startsWith('application/json')) {
|
|
return Language.JSON
|
|
}
|
|
if (contentType === 'text/x-python') {
|
|
return Language.Python
|
|
}
|
|
if (contentType === 'text/x-shellscript') {
|
|
return Language.Bash
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Bundled markdown files can carry their own YAML frontmatter; mirror the backend regex so it
|
|
// renders as a tidy block rather than a run-on paragraph the markdown renderer makes of it.
|
|
const BUNDLED_FRONTMATTER_RE = /^---[^\n]*\n([\s\S]*?)\n---[^\n]*\n?([\s\S]*)$/
|
|
|
|
function splitBundledFrontmatter(text: string): { frontmatter: string; body: string } {
|
|
const match = text.match(BUNDLED_FRONTMATTER_RE)
|
|
if (!match) {
|
|
return { frontmatter: '', body: text }
|
|
}
|
|
return { frontmatter: match[1].replace(/\s+$/, ''), body: match[2] }
|
|
}
|
|
|
|
function BundledMarkdown({ content }: { content: string }): JSX.Element {
|
|
const { frontmatter, body } = splitBundledFrontmatter(content)
|
|
return (
|
|
<>
|
|
{frontmatter && (
|
|
<pre className="mb-2 overflow-auto whitespace-pre-wrap rounded bg-fill-secondary px-2 py-1 font-mono text-xs text-muted">
|
|
{frontmatter}
|
|
</pre>
|
|
)}
|
|
<MarkdownWithMermaid className="text-sm" generateHeadingIds>
|
|
{body}
|
|
</MarkdownWithMermaid>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function SkillFileViewer({
|
|
skillName,
|
|
file,
|
|
version,
|
|
autoOpen = false,
|
|
}: {
|
|
skillName: string
|
|
file: LLMSkillFileManifestApi
|
|
version?: number
|
|
autoOpen?: boolean
|
|
}): JSX.Element {
|
|
const logicProps = { skillName, filePath: file.path, version }
|
|
const { expanded, content, contentLoading } = useValues(skillFileLogic(logicProps))
|
|
const { toggleExpand, autoOpen: triggerAutoOpen } = useActions(skillFileLogic(logicProps))
|
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!autoOpen) {
|
|
return
|
|
}
|
|
triggerAutoOpen()
|
|
const id = requestAnimationFrame(() => {
|
|
containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
})
|
|
return () => cancelAnimationFrame(id)
|
|
}, [autoOpen, triggerAutoOpen])
|
|
|
|
const isMarkdown = file.content_type === 'text/markdown' || file.path.endsWith('.md')
|
|
const codeLanguage = isMarkdown ? null : getFileLanguage(file.path, file.content_type)
|
|
|
|
const copyFileLink = (): void => {
|
|
const path = urls.skill(skillName, { file: file.path, version })
|
|
void copyToClipboard(urls.absolute(urls.currentProject(path)), 'file link')
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="rounded border">
|
|
<div className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-fill-secondary">
|
|
<button
|
|
type="button"
|
|
className="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0 text-left"
|
|
onClick={() => toggleExpand()}
|
|
data-attr={`llma-skill-file-toggle-${file.path}`}
|
|
>
|
|
<IconChevronRight
|
|
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${expanded ? 'rotate-90' : ''}`}
|
|
/>
|
|
<IconDocument className="h-3.5 w-3.5 shrink-0 text-muted" />
|
|
<span className="font-mono flex-1">{file.path}</span>
|
|
<span className="text-muted-alt text-xs">{file.content_type}</span>
|
|
</button>
|
|
<Button
|
|
size="xsmall"
|
|
noPadding
|
|
icon={<IconLink />}
|
|
tooltip="Copy link to this file"
|
|
onClick={copyFileLink}
|
|
data-attr={`llma-skill-file-copy-link-${file.path}`}
|
|
/>
|
|
</div>
|
|
{expanded && (
|
|
<div className="border-t bg-bg-light px-3 py-2">
|
|
{contentLoading ? (
|
|
<div className="space-y-2">
|
|
<Skeleton active className="h-3 w-full" />
|
|
<Skeleton active className="h-3 w-3/4" />
|
|
<Skeleton active className="h-3 w-1/2" />
|
|
</div>
|
|
) : content === null ? null : isMarkdown ? (
|
|
<BundledMarkdown content={content} />
|
|
) : codeLanguage !== null ? (
|
|
<CodeSnippet language={codeLanguage} compact thing={file.path} maxLinesWithoutExpansion={20}>
|
|
{content}
|
|
</CodeSnippet>
|
|
) : (
|
|
<pre className="max-h-80 overflow-auto text-xs whitespace-pre-wrap">{content}</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const COMMON_CONTENT_TYPES = [
|
|
{ value: 'text/plain', label: 'text/plain' },
|
|
{ value: 'text/markdown', label: 'text/markdown' },
|
|
{ value: 'text/x-python', label: 'text/x-python' },
|
|
{ value: 'text/x-shellscript', label: 'text/x-shellscript' },
|
|
{ value: 'application/json', label: 'application/json' },
|
|
{ value: 'text/yaml', label: 'text/yaml' },
|
|
{ value: 'text/javascript', label: 'text/javascript' },
|
|
{ value: 'text/typescript', label: 'text/typescript' },
|
|
]
|
|
|
|
function SkillEditForm({
|
|
isHistoricalVersion,
|
|
fileContentsLoading,
|
|
}: {
|
|
isHistoricalVersion: boolean
|
|
fileContentsLoading: boolean
|
|
}): JSX.Element {
|
|
const { isNewSkill, skillForm } = useValues(llmSkillLogic)
|
|
const { setSkillFormValues, addUploadedFiles } = useActions(llmSkillLogic)
|
|
const uploadInputRef = useRef<HTMLInputElement | null>(null)
|
|
// Depth counter instead of a boolean: dragenter/dragleave also fire for child elements,
|
|
// so a plain boolean would flicker off while moving across the section.
|
|
const dragDepthRef = useRef(0)
|
|
const [dropActive, setDropActive] = useState(false)
|
|
|
|
const onDragEnter = (e: React.DragEvent): void => {
|
|
e.preventDefault()
|
|
dragDepthRef.current += 1
|
|
setDropActive(true)
|
|
}
|
|
|
|
const onDragOver = (e: React.DragEvent): void => {
|
|
e.preventDefault()
|
|
}
|
|
|
|
const onDragLeave = (e: React.DragEvent): void => {
|
|
e.preventDefault()
|
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
|
if (dragDepthRef.current === 0) {
|
|
setDropActive(false)
|
|
}
|
|
}
|
|
|
|
const onDrop = (e: React.DragEvent): void => {
|
|
e.preventDefault()
|
|
dragDepthRef.current = 0
|
|
setDropActive(false)
|
|
void collectFilesFromDrop(e.dataTransfer)
|
|
.then((files) => {
|
|
if (files.length > 0) {
|
|
addUploadedFiles(files)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
toast.error('Couldn\'t read the dropped files. Try the "Upload files" button instead.')
|
|
})
|
|
}
|
|
|
|
const addFile = (): void => {
|
|
setSkillFormValues({
|
|
files: [...skillForm.files, { path: '', content: '', content_type: 'text/plain' }],
|
|
})
|
|
}
|
|
|
|
const removeFile = (index: number): void => {
|
|
setSkillFormValues({
|
|
files: skillForm.files.filter((_, i) => i !== index),
|
|
})
|
|
}
|
|
|
|
const updateFile = (index: number, field: keyof SkillFormFileValues, value: string): void => {
|
|
const updated = skillForm.files.map((f, i) => (i === index ? { ...f, [field]: value } : f))
|
|
setSkillFormValues({ files: updated })
|
|
}
|
|
|
|
return (
|
|
<div className="mt-4 max-w-3xl space-y-4">
|
|
{isHistoricalVersion && (
|
|
<div className="rounded border border-warning bg-warning-highlight p-3 text-sm">
|
|
You are publishing a new latest version from a historical version. The original version will remain
|
|
unchanged.
|
|
</div>
|
|
)}
|
|
|
|
<Field
|
|
name="name"
|
|
label="Name"
|
|
help={
|
|
isNewSkill
|
|
? `Lowercase letters, numbers, and hyphens only. Max ${SKILL_NAME_MAX_LENGTH} characters. Cannot be changed later.`
|
|
: 'This name is used to fetch the skill from your code.'
|
|
}
|
|
>
|
|
<Input
|
|
data-attr="llma-skill-name-input"
|
|
placeholder="my-skill-name"
|
|
maxLength={SKILL_NAME_MAX_LENGTH}
|
|
fullWidth
|
|
disabledReason={!isNewSkill ? 'Skill name cannot be changed after creation' : undefined}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
name="description"
|
|
label="Description"
|
|
help="Explain what this skill does and when to use it. Agents use this to discover the right skill for a task."
|
|
>
|
|
<TextArea
|
|
data-attr="llma-skill-description-input"
|
|
placeholder="Extract PDF text, fill forms, merge files. Use when handling PDFs."
|
|
maxLength={SKILL_DESCRIPTION_MAX_LENGTH}
|
|
minRows={2}
|
|
maxRows={4}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
name="body"
|
|
label="Skill body"
|
|
help="The main instruction content (SKILL.md body). Write markdown that tells agents how to perform the task."
|
|
>
|
|
<TextArea
|
|
data-attr="llma-skill-body-input"
|
|
placeholder="# My Skill ## When to use Use this skill when... ## Steps 1. First... 2. Then..."
|
|
minRows={10}
|
|
className="font-mono"
|
|
/>
|
|
</Field>
|
|
|
|
<Field name="license" label="License" help="Optional. License name or reference.">
|
|
<Input data-attr="llma-skill-license-input" placeholder="Apache-2.0" maxLength={255} fullWidth />
|
|
</Field>
|
|
|
|
<Field
|
|
name="compatibility"
|
|
label="Compatibility"
|
|
help="Optional. Environment requirements (intended product, system packages, network access)."
|
|
>
|
|
<Input
|
|
data-attr="llma-skill-compatibility-input"
|
|
placeholder="Requires git, docker, and internet access"
|
|
maxLength={500}
|
|
fullWidth
|
|
/>
|
|
</Field>
|
|
|
|
<div
|
|
onDragEnter={onDragEnter}
|
|
onDragOver={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
onDrop={onDrop}
|
|
className={dropActive ? 'rounded outline-2 outline-dashed outline-accent outline-offset-4' : undefined}
|
|
>
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<div>
|
|
<label className="text-sm font-semibold">Bundled files</label>
|
|
<p className="text-xs text-secondary">
|
|
Scripts, references, or assets bundled with this skill. Files are sent to agents alongside
|
|
the skill body.
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<input
|
|
ref={uploadInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const files = Array.from(e.target.files ?? [])
|
|
if (files.length > 0) {
|
|
addUploadedFiles(files.map((file) => ({ path: file.name, file })))
|
|
}
|
|
e.target.value = ''
|
|
}}
|
|
/>
|
|
<Button
|
|
type="secondary"
|
|
icon={<IconUpload />}
|
|
size="small"
|
|
onClick={() => uploadInputRef.current?.click()}
|
|
data-attr="llma-skill-upload-files-button"
|
|
>
|
|
Upload files
|
|
</Button>
|
|
<Button
|
|
type="secondary"
|
|
icon={<IconPlus />}
|
|
size="small"
|
|
onClick={addFile}
|
|
data-attr="llma-skill-add-file-button"
|
|
>
|
|
Add file
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{fileContentsLoading ? (
|
|
<div className="space-y-2 rounded border p-3">
|
|
<Skeleton active className="h-3 w-1/3" />
|
|
<Skeleton active className="h-3 w-full" />
|
|
<Skeleton active className="h-3 w-2/3" />
|
|
</div>
|
|
) : skillForm.files.length === 0 ? (
|
|
<div className="rounded border border-dashed p-4 text-center text-sm text-secondary">
|
|
No bundled files. Upload files, drag and drop files or folders here, or click "Add file" to
|
|
write one from scratch.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{skillForm.files.map((file, index) => (
|
|
<SkillFileEditor
|
|
key={index}
|
|
file={file}
|
|
index={index}
|
|
onUpdate={updateFile}
|
|
onRemove={removeFile}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SkillFileEditor({
|
|
file,
|
|
index,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
file: SkillFormFileValues
|
|
index: number
|
|
onUpdate: (index: number, field: keyof SkillFormFileValues, value: string) => void
|
|
onRemove: (index: number) => void
|
|
}): JSX.Element {
|
|
const [collapsed, setCollapsed] = useState(false)
|
|
|
|
return (
|
|
<div className="rounded border">
|
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
|
<button
|
|
type="button"
|
|
className="flex cursor-pointer items-center border-none bg-transparent p-0"
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
>
|
|
<IconChevronRight
|
|
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${!collapsed ? 'rotate-90' : ''}`}
|
|
/>
|
|
</button>
|
|
<IconDocument className="h-3.5 w-3.5 shrink-0 text-muted" />
|
|
<span className="font-mono flex-1 text-sm">{file.path || 'New file'}</span>
|
|
<Button
|
|
icon={<IconX />}
|
|
size="xsmall"
|
|
status="danger"
|
|
onClick={() => onRemove(index)}
|
|
tooltip="Remove file"
|
|
data-attr={`llma-skill-remove-file-${index}`}
|
|
/>
|
|
</div>
|
|
{!collapsed && (
|
|
<div className="space-y-3 p-3">
|
|
<div className="flex gap-3">
|
|
<div className="flex-1">
|
|
<label className="mb-1 block text-xs font-medium text-secondary">Path</label>
|
|
<Input
|
|
data-attr={`llma-skill-file-path-${index}`}
|
|
value={file.path}
|
|
onChange={(val) => onUpdate(index, 'path', val)}
|
|
placeholder="scripts/setup.sh"
|
|
fullWidth
|
|
size="small"
|
|
/>
|
|
</div>
|
|
<div className="w-48">
|
|
<label className="mb-1 block text-xs font-medium text-secondary">Content type</label>
|
|
<Select
|
|
data-attr={`llma-skill-file-content-type-${index}`}
|
|
value={file.content_type}
|
|
onChange={(val) => onUpdate(index, 'content_type', val)}
|
|
options={COMMON_CONTENT_TYPES}
|
|
size="small"
|
|
fullWidth
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-secondary">Content</label>
|
|
<TextArea
|
|
data-attr={`llma-skill-file-content-${index}`}
|
|
value={file.content}
|
|
onChange={(val) => onUpdate(index, 'content', val)}
|
|
placeholder="File content..."
|
|
minRows={4}
|
|
className="font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SkillVersionSidebar({
|
|
skillName,
|
|
skill,
|
|
versions,
|
|
versionsLoading,
|
|
canLoadMoreVersions,
|
|
loadMoreVersions,
|
|
searchParams,
|
|
}: {
|
|
skillName: string
|
|
skill: { id: string; version: number; version_count: number } | null
|
|
versions: LLMSkillVersionSummaryApi[]
|
|
versionsLoading: boolean
|
|
canLoadMoreVersions: boolean
|
|
loadMoreVersions: () => void
|
|
searchParams: Record<string, any>
|
|
}): JSX.Element {
|
|
const { compareVersion } = useValues(llmSkillLogic)
|
|
const { setCompareVersion } = useActions(llmSkillLogic)
|
|
|
|
return (
|
|
<aside className="w-full shrink-0 2xl:sticky 2xl:top-4 2xl:mt-3 2xl:w-80">
|
|
<div className="rounded border bg-surface-primary p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="font-semibold">Version history</h3>
|
|
<p className="text-sm text-secondary">
|
|
{versions.length} of {skill?.version_count ?? versions.length} loaded
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-h-[28rem] space-y-2 overflow-y-auto pr-1">
|
|
{versions.map((versionSkill) => {
|
|
const selected = skill?.id === versionSkill.id
|
|
const isCompareTarget = compareVersion === versionSkill.version
|
|
const canCompare = skill?.version !== versionSkill.version
|
|
const cleanedParams = { ...searchParams }
|
|
delete cleanedParams.edit
|
|
const versionUrl = combineUrl(urls.skill(skillName), {
|
|
...cleanedParams,
|
|
version: versionSkill.is_latest ? undefined : versionSkill.version,
|
|
}).url
|
|
|
|
return (
|
|
<Link
|
|
key={versionSkill.id}
|
|
to={versionUrl}
|
|
className={`block rounded border p-3 no-underline ${
|
|
selected
|
|
? 'border-primary bg-primary-highlight'
|
|
: isCompareTarget
|
|
? 'border-warning bg-warning-highlight'
|
|
: 'border-primary/10 hover:bg-fill-secondary'
|
|
}`}
|
|
data-attr={`llma-skill-version-link-${versionSkill.version}`}
|
|
>
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-sm">v{versionSkill.version}</span>
|
|
{versionSkill.is_latest ? (
|
|
<Tag type="success" size="small">
|
|
Latest
|
|
</Tag>
|
|
) : null}
|
|
{isCompareTarget ? (
|
|
<Tag type="warning" size="small">
|
|
Comparing
|
|
</Tag>
|
|
) : null}
|
|
</div>
|
|
{canCompare && (
|
|
<Button
|
|
size="xsmall"
|
|
noPadding
|
|
icon={<IconColumns />}
|
|
tooltip={
|
|
isCompareTarget
|
|
? 'Stop comparing'
|
|
: `Compare with v${versionSkill.version}`
|
|
}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setCompareVersion(isCompareTarget ? null : versionSkill.version)
|
|
}}
|
|
data-attr={`llma-skill-compare-version-${versionSkill.version}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-secondary">
|
|
{dayjs(versionSkill.created_at).format('MMM D, YYYY h:mm A')}
|
|
</div>
|
|
{versionSkill.created_by?.email ? (
|
|
<div className="mt-1 text-xs text-secondary">{versionSkill.created_by.email}</div>
|
|
) : null}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{canLoadMoreVersions ? (
|
|
<Button
|
|
className="mt-3 w-full"
|
|
type="secondary"
|
|
onClick={loadMoreVersions}
|
|
loading={versionsLoading}
|
|
data-attr="llma-skill-load-more-versions"
|
|
>
|
|
Load more versions
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|