mirror of
https://github.com/hanzoai/insights.git
synced 2026-11-03 00:52:32 +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.
903 lines
31 KiB
TypeScript
903 lines
31 KiB
TypeScript
import {
|
|
MakeLogicType,
|
|
actions,
|
|
afterMount,
|
|
defaults,
|
|
kea,
|
|
key,
|
|
listeners,
|
|
path,
|
|
props,
|
|
reducers,
|
|
selectors,
|
|
} from 'kea'
|
|
import { forms } from 'kea-forms'
|
|
import type { DeepPartial, DeepPartialMap, FieldName, ValidationErrorType } from 'kea-forms'
|
|
import { loaders } from 'kea-loaders'
|
|
import { combineUrl, router, urlToAction } from 'kea-router'
|
|
|
|
import { ApiConfig } from '~/lib/api'
|
|
import { toast } from '~/lib/elements/Toast/Toast'
|
|
import { urls } from '~/scenes/urls'
|
|
import { Breadcrumb } from '~/types'
|
|
|
|
import {
|
|
llmSkillsCreate,
|
|
llmSkillsNameArchiveCreate,
|
|
llmSkillsNameFilesRetrieve,
|
|
llmSkillsNamePartialUpdate,
|
|
llmSkillsResolveNameRetrieve,
|
|
} from 'products/skills/frontend/generated/api'
|
|
import type {
|
|
LLMSkillApi,
|
|
LLMSkillFileApi,
|
|
LLMSkillFileInputApi,
|
|
LLMSkillListApi,
|
|
LLMSkillVersionSummaryApi,
|
|
} from 'products/skills/frontend/generated/api.schemas'
|
|
|
|
import { exportAndDownloadSkill, llmSkillsLogic, LLM_SKILLS_FORCE_RELOAD_PARAM } from './llmSkillsLogic'
|
|
import {
|
|
SKILL_DESCRIPTION_MAX_LENGTH,
|
|
SKILL_FILE_MAX_BYTES,
|
|
SKILL_FILE_MAX_COUNT,
|
|
validateSkillName,
|
|
} from './skillConstants'
|
|
import type { SkillFileUpload } from './skillFileUpload'
|
|
|
|
export enum SkillMode {
|
|
View = 'view',
|
|
Edit = 'edit',
|
|
}
|
|
|
|
export interface SkillLogicProps {
|
|
skillName: string | 'new'
|
|
mode?: SkillMode
|
|
selectedVersion?: number | null
|
|
}
|
|
|
|
export interface SkillFormFileValues {
|
|
path: string
|
|
content: string
|
|
content_type: string
|
|
}
|
|
|
|
export interface SkillFormValues {
|
|
name: string
|
|
description: string
|
|
body: string
|
|
license: string
|
|
compatibility: string
|
|
files: SkillFormFileValues[]
|
|
}
|
|
|
|
export interface ResolvedLLMSkill extends LLMSkillApi {
|
|
versions: LLMSkillVersionSummaryApi[]
|
|
has_more: boolean
|
|
}
|
|
|
|
export function isSkill(skill: LLMSkillApi | ResolvedLLMSkill | SkillFormValues | null): skill is ResolvedLLMSkill {
|
|
return skill !== null && 'id' in skill
|
|
}
|
|
|
|
// Values must stay within COMMON_CONTENT_TYPES in LLMSkillScene, so the content-type
|
|
// select can display whatever gets inferred here.
|
|
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
|
md: 'text/markdown',
|
|
markdown: 'text/markdown',
|
|
py: 'text/x-python',
|
|
sh: 'text/x-shellscript',
|
|
bash: 'text/x-shellscript',
|
|
zsh: 'text/x-shellscript',
|
|
json: 'application/json',
|
|
yaml: 'text/yaml',
|
|
yml: 'text/yaml',
|
|
js: 'text/javascript',
|
|
mjs: 'text/javascript',
|
|
cjs: 'text/javascript',
|
|
jsx: 'text/javascript',
|
|
ts: 'text/typescript',
|
|
tsx: 'text/typescript',
|
|
}
|
|
|
|
function inferFileContentType(fileName: string): string {
|
|
const extension = fileName.toLowerCase().split('.').pop() ?? ''
|
|
return CONTENT_TYPE_BY_EXTENSION[extension] ?? 'text/plain'
|
|
}
|
|
|
|
const DEFAULT_SKILL_FORM_VALUES: SkillFormValues = {
|
|
name: '',
|
|
description: '',
|
|
body: '',
|
|
license: '',
|
|
compatibility: '',
|
|
files: [],
|
|
}
|
|
|
|
const SKILL_VERSIONS_LIMIT = 50
|
|
|
|
async function fetchResolvedSkill(
|
|
skillName: string,
|
|
params?: { version?: number; offset?: number; before_version?: number; limit?: number }
|
|
): Promise<ResolvedLLMSkill> {
|
|
const response = await llmSkillsResolveNameRetrieve(String(ApiConfig.getCurrentTeamId()), skillName, {
|
|
...params,
|
|
limit: params?.limit ?? SKILL_VERSIONS_LIMIT,
|
|
})
|
|
return {
|
|
...response.skill,
|
|
versions: response.versions,
|
|
has_more: response.has_more,
|
|
}
|
|
}
|
|
|
|
function getSkillFormDefaults(skill: LLMSkillApi, fileContents?: LLMSkillFileApi[]): SkillFormValues {
|
|
const files: SkillFormFileValues[] = fileContents
|
|
? fileContents.map((f) => ({ path: f.path, content: f.content, content_type: f.content_type || 'text/plain' }))
|
|
: skill.files.map((f) => ({ path: f.path, content: '', content_type: f.content_type || 'text/plain' }))
|
|
return {
|
|
name: skill.name,
|
|
description: skill.description,
|
|
body: skill.body,
|
|
license: skill.license || '',
|
|
compatibility: skill.compatibility || '',
|
|
files,
|
|
}
|
|
}
|
|
|
|
async function fetchAllFileContents(skillName: string, skill: LLMSkillApi): Promise<LLMSkillFileApi[]> {
|
|
if (!skill.files || skill.files.length === 0) {
|
|
return []
|
|
}
|
|
const teamId = String(ApiConfig.getCurrentTeamId())
|
|
const results = await Promise.all(
|
|
skill.files.map((f) =>
|
|
llmSkillsNameFilesRetrieve(teamId, skillName, f.path, {
|
|
version: skill.is_latest ? undefined : skill.version,
|
|
})
|
|
)
|
|
)
|
|
return results
|
|
}
|
|
|
|
function findExistingSkill(skillName: string): LLMSkillListApi | undefined {
|
|
return llmSkillsLogic.findMounted()?.values.skills.results.find((s) => s.name === skillName)
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface llmSkillLogicValues {
|
|
breadcrumbs: Breadcrumb[]
|
|
canCompareVersions: boolean
|
|
canLoadMoreVersions: boolean
|
|
compareSkill: LLMSkillApi | null
|
|
compareSkillLoading: boolean
|
|
compareVersion: number | null
|
|
compareVersionOptions: Array<{
|
|
label: string
|
|
value: number
|
|
}>
|
|
downloadingZip: boolean
|
|
fileContentsLoading: boolean
|
|
isDiffVisible: boolean
|
|
isEditMode: boolean
|
|
isHistoricalVersion: boolean
|
|
isNewSkill: boolean
|
|
isOutlineExpanded: boolean
|
|
isSkillFormSubmitting: boolean
|
|
isSkillFormValid: boolean
|
|
isSkillMissing: boolean
|
|
isViewMode: boolean
|
|
mode: SkillMode
|
|
shouldDisplaySkeleton: boolean
|
|
showSkillFormErrors: boolean
|
|
skill: ResolvedLLMSkill | SkillFormValues | null
|
|
skillFetched: boolean
|
|
skillForm: SkillFormValues
|
|
skillFormAllErrors: Record<string, any>
|
|
skillFormChanged: boolean
|
|
skillFormErrors: DeepPartialMap<SkillFormValues, ValidationErrorType>
|
|
skillFormHasErrors: boolean
|
|
skillFormManualErrors: Record<string, any>
|
|
skillFormTouched: boolean
|
|
skillFormTouches: Record<string, boolean>
|
|
skillFormValidationErrors: DeepPartialMap<SkillFormValues, ValidationErrorType>
|
|
skillLoading: boolean
|
|
versions: LLMSkillVersionSummaryApi[]
|
|
versionsLoading: boolean
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface llmSkillLogicActions {
|
|
addUploadedFiles: (files: SkillFileUpload[]) => {
|
|
files: SkillFileUpload[]
|
|
}
|
|
deleteSkill: () => {
|
|
value: true
|
|
}
|
|
downloadSkill: () => {
|
|
value: true
|
|
}
|
|
loadCompareSkill: (version: number) => number
|
|
loadCompareSkillFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadCompareSkillSuccess: (
|
|
compareSkill: LLMSkillApi,
|
|
payload?: number
|
|
) => {
|
|
compareSkill: LLMSkillApi
|
|
payload?: number
|
|
}
|
|
loadFileContents: () => {
|
|
value: true
|
|
}
|
|
loadMoreVersions: () => {
|
|
value: true
|
|
}
|
|
loadSkill: () => any
|
|
loadSkillFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadSkillSuccess: (
|
|
skill: ResolvedLLMSkill,
|
|
payload?: any
|
|
) => {
|
|
skill: ResolvedLLMSkill
|
|
payload?: any
|
|
}
|
|
resetSkillForm: (values?: SkillFormValues) => {
|
|
values?: SkillFormValues
|
|
}
|
|
setCompareVersion: (compareVersion: number | null) => {
|
|
compareVersion: number | null
|
|
}
|
|
setDownloadingZip: (downloadingZip: boolean) => {
|
|
downloadingZip: boolean
|
|
}
|
|
setFileContentsLoading: (loading: boolean) => {
|
|
loading: boolean
|
|
}
|
|
setMode: (mode: SkillMode) => {
|
|
mode: SkillMode
|
|
}
|
|
setSkill: (skill: ResolvedLLMSkill | SkillFormValues) => {
|
|
skill: ResolvedLLMSkill | SkillFormValues
|
|
}
|
|
setSkillFormManualErrors: (errors: Record<string, any>) => {
|
|
errors: Record<string, any>
|
|
}
|
|
setSkillFormValue: (
|
|
key: FieldName,
|
|
value: any
|
|
) => {
|
|
name: FieldName
|
|
value: any
|
|
}
|
|
setSkillFormValues: (values: DeepPartial<SkillFormValues>) => {
|
|
values: DeepPartial<SkillFormValues>
|
|
}
|
|
setVersionsLoading: (versionsLoading: boolean) => {
|
|
versionsLoading: boolean
|
|
}
|
|
submitSkillForm: () => {
|
|
value: boolean
|
|
}
|
|
submitSkillFormFailure: (
|
|
error: Error,
|
|
errors: Record<string, any>
|
|
) => {
|
|
error: Error
|
|
errors: Record<string, any>
|
|
}
|
|
submitSkillFormRequest: (skillForm: SkillFormValues) => {
|
|
skillForm: SkillFormValues
|
|
}
|
|
submitSkillFormSuccess: (skillForm: SkillFormValues) => {
|
|
skillForm: SkillFormValues
|
|
}
|
|
toggleOutlineExpanded: () => {
|
|
value: true
|
|
}
|
|
touchSkillFormField: (key: string) => {
|
|
key: string
|
|
}
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface llmSkillLogicMeta {
|
|
key: string
|
|
__keaTypeGenInternalSelectorTypes: {
|
|
isNewSkill: (arg: any) => boolean
|
|
isSkillMissing: (
|
|
skill: ResolvedLLMSkill | SkillFormValues | null,
|
|
skillLoading: boolean,
|
|
skillFetched: boolean
|
|
) => boolean
|
|
shouldDisplaySkeleton: (
|
|
skill: ResolvedLLMSkill | SkillFormValues | null,
|
|
skillLoading: boolean,
|
|
skillFetched: boolean,
|
|
isNewSkill: boolean
|
|
) => boolean
|
|
isHistoricalVersion: (skill: ResolvedLLMSkill | SkillFormValues | null) => boolean
|
|
breadcrumbs: (
|
|
skill: ResolvedLLMSkill | SkillFormValues | null,
|
|
searchParams: Record<string, any>
|
|
) => Breadcrumb[]
|
|
isViewMode: (mode: SkillMode, arg: any) => boolean
|
|
isEditMode: (mode: SkillMode, arg: any) => boolean
|
|
versions: (skill: ResolvedLLMSkill | SkillFormValues | null) => LLMSkillVersionSummaryApi[]
|
|
canLoadMoreVersions: (skill: ResolvedLLMSkill | SkillFormValues | null) => boolean
|
|
isDiffVisible: (compareVersion: number | null) => boolean
|
|
canCompareVersions: (skill: ResolvedLLMSkill | SkillFormValues | null) => boolean
|
|
compareVersionOptions: (
|
|
skill: ResolvedLLMSkill | SkillFormValues | null,
|
|
versions: LLMSkillVersionSummaryApi[]
|
|
) => Array<{
|
|
label: string
|
|
value: number
|
|
}>
|
|
}
|
|
}
|
|
|
|
export type llmSkillLogicType = MakeLogicType<
|
|
llmSkillLogicValues,
|
|
llmSkillLogicActions,
|
|
SkillLogicProps,
|
|
llmSkillLogicMeta
|
|
>
|
|
|
|
export const llmSkillLogic = kea<llmSkillLogicType>([
|
|
path(['scenes', 'skills', 'llmSkillLogic']),
|
|
props({ skillName: 'new' } as SkillLogicProps),
|
|
key(({ skillName, selectedVersion }) => `skill-${skillName}:${selectedVersion ?? 'latest'}`),
|
|
actions({
|
|
addUploadedFiles: (files: SkillFileUpload[]) => ({ files }),
|
|
setSkill: (skill: ResolvedLLMSkill | SkillFormValues) => ({ skill }),
|
|
deleteSkill: true,
|
|
loadMoreVersions: true,
|
|
setVersionsLoading: (versionsLoading: boolean) => ({ versionsLoading }),
|
|
setMode: (mode: SkillMode) => ({ mode }),
|
|
loadFileContents: true,
|
|
setFileContentsLoading: (loading: boolean) => ({ loading }),
|
|
toggleOutlineExpanded: true,
|
|
setCompareVersion: (compareVersion: number | null) => ({ compareVersion }),
|
|
downloadSkill: true,
|
|
setDownloadingZip: (downloadingZip: boolean) => ({ downloadingZip }),
|
|
}),
|
|
|
|
reducers(({ props }) => ({
|
|
skill: [
|
|
null as ResolvedLLMSkill | SkillFormValues | null,
|
|
{
|
|
loadSkillSuccess: (_, { skill }) => skill,
|
|
setSkill: (_, { skill }) => skill,
|
|
},
|
|
],
|
|
skillFetched: [
|
|
props.skillName === 'new',
|
|
{
|
|
loadSkillSuccess: () => true,
|
|
loadSkillFailure: () => true,
|
|
},
|
|
],
|
|
versionsLoading: [
|
|
false,
|
|
{
|
|
loadMoreVersions: () => true,
|
|
setVersionsLoading: (_, { versionsLoading }) => versionsLoading,
|
|
loadSkillSuccess: () => false,
|
|
},
|
|
],
|
|
fileContentsLoading: [
|
|
false,
|
|
{
|
|
loadFileContents: () => true,
|
|
setFileContentsLoading: (_, { loading }) => loading,
|
|
},
|
|
],
|
|
isOutlineExpanded: [
|
|
false,
|
|
{
|
|
toggleOutlineExpanded: (state) => !state,
|
|
},
|
|
],
|
|
mode: [
|
|
props.mode ?? SkillMode.View,
|
|
{
|
|
setMode: (_, { mode }) => mode,
|
|
},
|
|
],
|
|
compareVersion: [
|
|
null as number | null,
|
|
{
|
|
setCompareVersion: (_, { compareVersion }) => compareVersion,
|
|
loadSkillSuccess: () => null,
|
|
},
|
|
],
|
|
compareSkill: [
|
|
null as LLMSkillApi | null,
|
|
{
|
|
setCompareVersion: () => null,
|
|
loadSkillSuccess: () => null,
|
|
},
|
|
],
|
|
downloadingZip: [
|
|
false,
|
|
{
|
|
setDownloadingZip: (_, { downloadingZip }) => downloadingZip,
|
|
},
|
|
],
|
|
})),
|
|
|
|
loaders(({ props }) => ({
|
|
skill: {
|
|
__default: null as ResolvedLLMSkill | SkillFormValues | null,
|
|
loadSkill: async () =>
|
|
fetchResolvedSkill(props.skillName, {
|
|
version: props.selectedVersion ?? undefined,
|
|
}),
|
|
},
|
|
compareSkill: {
|
|
__default: null as LLMSkillApi | null,
|
|
loadCompareSkill: async (version: number) => {
|
|
const resolved = await fetchResolvedSkill(props.skillName, { version, limit: 1 })
|
|
return resolved as LLMSkillApi
|
|
},
|
|
},
|
|
})),
|
|
|
|
forms(({ actions, props, values }) => ({
|
|
skillForm: {
|
|
defaults: DEFAULT_SKILL_FORM_VALUES,
|
|
options: { showErrorsOnTouch: true },
|
|
|
|
errors: ({ name, description, body }) => ({
|
|
name: validateSkillName(name),
|
|
description: !description?.trim()
|
|
? 'Description is required'
|
|
: description.length > SKILL_DESCRIPTION_MAX_LENGTH
|
|
? `Description must be ${SKILL_DESCRIPTION_MAX_LENGTH} characters or fewer`
|
|
: undefined,
|
|
body: !body?.trim() ? 'Skill body is required' : undefined,
|
|
}),
|
|
|
|
submit: async (formValues) => {
|
|
const isNew = props.skillName === 'new'
|
|
|
|
try {
|
|
let savedSkill: LLMSkillApi
|
|
|
|
const filesToSend: LLMSkillFileInputApi[] | undefined =
|
|
formValues.files.length > 0
|
|
? formValues.files.map((f) => ({
|
|
path: f.path,
|
|
content: f.content,
|
|
content_type: f.content_type || undefined,
|
|
}))
|
|
: undefined
|
|
|
|
if (isNew) {
|
|
const createResponse = await llmSkillsCreate(String(ApiConfig.getCurrentTeamId()), {
|
|
name: formValues.name,
|
|
description: formValues.description,
|
|
body: formValues.body,
|
|
license: formValues.license || undefined,
|
|
compatibility: formValues.compatibility || undefined,
|
|
files: filesToSend,
|
|
})
|
|
savedSkill = { ...createResponse, files: [] }
|
|
llmSkillsLogic.findMounted()?.actions.loadSkills(false)
|
|
toast.success('Skill created successfully')
|
|
router.actions.replace(urls.skill(savedSkill.name))
|
|
} else {
|
|
const currentSkill = values.skill
|
|
|
|
if (!isSkill(currentSkill)) {
|
|
throw new Error('Cannot publish skill version: skill data not loaded')
|
|
}
|
|
|
|
savedSkill = await llmSkillsNamePartialUpdate(
|
|
String(ApiConfig.getCurrentTeamId()),
|
|
props.skillName,
|
|
{
|
|
body: formValues.body,
|
|
description: formValues.description,
|
|
license: formValues.license || undefined,
|
|
compatibility: formValues.compatibility || undefined,
|
|
allowed_tools: currentSkill.allowed_tools,
|
|
metadata: currentSkill.metadata,
|
|
base_version: currentSkill.latest_version,
|
|
files: filesToSend,
|
|
}
|
|
)
|
|
llmSkillsLogic.findMounted()?.actions.loadSkills(false)
|
|
toast.success('Skill version published successfully')
|
|
|
|
actions.setSkill({
|
|
...savedSkill,
|
|
versions: [
|
|
{
|
|
id: savedSkill.id,
|
|
version: savedSkill.version,
|
|
created_by: savedSkill.created_by,
|
|
created_at: savedSkill.created_at,
|
|
is_latest: true,
|
|
},
|
|
...currentSkill.versions
|
|
.filter((v) => v.id !== savedSkill.id)
|
|
.map((v) => ({ ...v, is_latest: false })),
|
|
],
|
|
has_more: currentSkill.has_more,
|
|
})
|
|
actions.setSkillFormValues(getSkillFormDefaults(savedSkill))
|
|
router.actions.replace(urls.skill(props.skillName))
|
|
|
|
try {
|
|
const latest = await fetchResolvedSkill(props.skillName)
|
|
actions.setSkill(latest)
|
|
actions.setSkillFormValues(getSkillFormDefaults(latest))
|
|
} catch (err) {
|
|
console.error('Failed to refresh skill after publish', err)
|
|
}
|
|
}
|
|
|
|
actions.setMode(SkillMode.View)
|
|
if (isNew) {
|
|
actions.setSkill({
|
|
...savedSkill,
|
|
versions: [],
|
|
has_more: false,
|
|
})
|
|
actions.setSkillFormValues(getSkillFormDefaults(savedSkill))
|
|
}
|
|
} catch (error: unknown) {
|
|
const detail =
|
|
error !== null && typeof error === 'object' && 'detail' in error
|
|
? (error as { detail: string }).detail
|
|
: undefined
|
|
toast.error(detail || 'Failed to save skill')
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
})),
|
|
|
|
selectors({
|
|
isNewSkill: [() => [(_, props) => props], (props) => props.skillName === 'new'],
|
|
|
|
isSkillMissing: [
|
|
(s) => [s.skill, s.skillLoading, s.skillFetched],
|
|
(skill: ResolvedLLMSkill | SkillFormValues | null, skillLoading: boolean, skillFetched: boolean) =>
|
|
skillFetched && !skillLoading && skill === null,
|
|
],
|
|
|
|
shouldDisplaySkeleton: [
|
|
(s) => [s.skill, s.skillLoading, s.skillFetched, s.isNewSkill],
|
|
(
|
|
skill: ResolvedLLMSkill | SkillFormValues | null,
|
|
skillLoading: boolean,
|
|
skillFetched: boolean,
|
|
isNewSkill: boolean
|
|
) => !isNewSkill && (!skillFetched || (skillLoading && skill === null)),
|
|
],
|
|
|
|
isHistoricalVersion: [
|
|
(s) => [s.skill],
|
|
(skill: ResolvedLLMSkill | SkillFormValues | null) => (isSkill(skill) ? !skill.is_latest : false),
|
|
],
|
|
|
|
breadcrumbs: [
|
|
(s) => [s.skill, router.selectors.searchParams],
|
|
(skill: LLMSkillApi | SkillFormValues | null, searchParams: Record<string, any>): Breadcrumb[] => [
|
|
{
|
|
name: 'Skills',
|
|
path: combineUrl(urls.skills(), searchParams).url,
|
|
key: 'Skills',
|
|
},
|
|
{
|
|
name:
|
|
skill && 'name' in skill
|
|
? isSkill(skill)
|
|
? `${skill.name} v${skill.version}`
|
|
: skill.name || 'New skill'
|
|
: 'New skill',
|
|
key: 'Skill',
|
|
},
|
|
],
|
|
],
|
|
|
|
isViewMode: [
|
|
(s) => [s.mode, (_, props) => props],
|
|
(mode: SkillMode, props) => props.skillName !== 'new' && mode === SkillMode.View,
|
|
],
|
|
|
|
isEditMode: [
|
|
(s) => [s.mode, (_, props) => props],
|
|
(mode: SkillMode, props) => props.skillName === 'new' || mode === SkillMode.Edit,
|
|
],
|
|
|
|
versions: [
|
|
(s) => [s.skill],
|
|
(skill: ResolvedLLMSkill | SkillFormValues | null): LLMSkillVersionSummaryApi[] =>
|
|
isSkill(skill) ? skill.versions : [],
|
|
],
|
|
|
|
canLoadMoreVersions: [
|
|
(s) => [s.skill],
|
|
(skill: ResolvedLLMSkill | SkillFormValues | null) => (isSkill(skill) ? skill.has_more : false),
|
|
],
|
|
|
|
isDiffVisible: [(s) => [s.compareVersion], (compareVersion: number | null): boolean => compareVersion !== null],
|
|
|
|
canCompareVersions: [
|
|
(s) => [s.skill],
|
|
(skill: ResolvedLLMSkill | SkillFormValues | null): boolean => isSkill(skill) && skill.version_count > 1,
|
|
],
|
|
|
|
compareVersionOptions: [
|
|
(s) => [s.skill, s.versions],
|
|
(
|
|
skill: ResolvedLLMSkill | SkillFormValues | null,
|
|
versions: LLMSkillVersionSummaryApi[]
|
|
): Array<{ value: number; label: string }> => {
|
|
if (!isSkill(skill)) {
|
|
return []
|
|
}
|
|
return versions
|
|
.filter((v) => v.version !== skill.version)
|
|
.map((v) => ({
|
|
value: v.version,
|
|
label: `v${v.version}${v.is_latest ? ' (latest)' : ''}`,
|
|
}))
|
|
},
|
|
],
|
|
}),
|
|
|
|
listeners(({ actions, props, values }) => ({
|
|
addUploadedFiles: async ({ files }) => {
|
|
const uploaded: SkillFormFileValues[] = []
|
|
for (const { path, file } of files) {
|
|
// Case-insensitive to match the backend's reserved-path validation
|
|
if (path.toLowerCase() === 'skill.md') {
|
|
toast.info(
|
|
"SKILL.md wasn't added as a bundled file. Its body belongs in the skill body field."
|
|
)
|
|
continue
|
|
}
|
|
if (file.size > SKILL_FILE_MAX_BYTES) {
|
|
toast.error(`Couldn't add ${path}: files must be 1 MB or smaller`)
|
|
continue
|
|
}
|
|
// Strict decoding distinguishes real binary (invalid UTF-8, or NUL bytes) from
|
|
// text that legitimately contains a U+FFFD replacement character.
|
|
let content: string
|
|
try {
|
|
content = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer())
|
|
} catch {
|
|
toast.error(`Couldn't add ${path}: only text files are supported`)
|
|
continue
|
|
}
|
|
if (content.includes('\u0000')) {
|
|
toast.error(`Couldn't add ${path}: only text files are supported`)
|
|
continue
|
|
}
|
|
uploaded.push({ path, content, content_type: inferFileContentType(path) })
|
|
}
|
|
if (uploaded.length === 0) {
|
|
return
|
|
}
|
|
const uploadedByPath = new Map(uploaded.map((f) => [f.path, f]))
|
|
const merged = values.skillForm.files.map((existing) => {
|
|
const replacement = uploadedByPath.get(existing.path)
|
|
if (replacement) {
|
|
uploadedByPath.delete(existing.path)
|
|
return replacement
|
|
}
|
|
return existing
|
|
})
|
|
const combined = [...merged, ...uploadedByPath.values()]
|
|
if (combined.length > SKILL_FILE_MAX_COUNT) {
|
|
toast.error(
|
|
`Some files weren't added: a skill can have at most ${SKILL_FILE_MAX_COUNT} bundled files`
|
|
)
|
|
}
|
|
actions.setSkillFormValues({ files: combined.slice(0, SKILL_FILE_MAX_COUNT) })
|
|
},
|
|
|
|
downloadSkill: async () => {
|
|
if (props.skillName === 'new') {
|
|
return
|
|
}
|
|
actions.setDownloadingZip(true)
|
|
try {
|
|
await exportAndDownloadSkill(props.skillName)
|
|
} catch (e) {
|
|
console.error('Failed to export skill', e)
|
|
const detail =
|
|
e !== null && typeof e === 'object' && 'detail' in e ? (e as { detail?: string }).detail : undefined
|
|
toast.error(detail || (e instanceof Error ? e.message : 'Failed to export skill'))
|
|
} finally {
|
|
actions.setDownloadingZip(false)
|
|
}
|
|
},
|
|
|
|
deleteSkill: async () => {
|
|
if (props.skillName !== 'new' && values.skill && isSkill(values.skill)) {
|
|
try {
|
|
await llmSkillsNameArchiveCreate(String(ApiConfig.getCurrentTeamId()), values.skill.name)
|
|
toast.info(`${values.skill.name || 'Skill'} has been archived.`)
|
|
llmSkillsLogic.findMounted()?.actions.loadSkills(false)
|
|
router.actions.replace(urls.skills(), {
|
|
...router.values.searchParams,
|
|
[LLM_SKILLS_FORCE_RELOAD_PARAM]: String(Date.now()),
|
|
})
|
|
} catch (e) {
|
|
console.error('Failed to archive skill', e)
|
|
toast.error('Failed to archive skill')
|
|
}
|
|
}
|
|
},
|
|
|
|
loadMoreVersions: async () => {
|
|
if (props.skillName === 'new' || !isSkill(values.skill)) {
|
|
actions.setVersionsLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const oldestLoadedVersion = values.skill.versions[values.skill.versions.length - 1]?.version
|
|
if (!oldestLoadedVersion) {
|
|
actions.setVersionsLoading(false)
|
|
return
|
|
}
|
|
|
|
const response = await fetchResolvedSkill(props.skillName, {
|
|
version: values.skill.version,
|
|
before_version: oldestLoadedVersion,
|
|
})
|
|
|
|
const existingVersionIds = new Set(values.skill.versions.map((v) => v.id))
|
|
const appendedVersions = response.versions.filter((v) => !existingVersionIds.has(v.id))
|
|
|
|
actions.setSkill({
|
|
...response,
|
|
versions: [...values.skill.versions, ...appendedVersions],
|
|
has_more: response.has_more,
|
|
})
|
|
} catch (e) {
|
|
console.error('Failed to load more versions', e)
|
|
toast.error('Failed to load more versions')
|
|
} finally {
|
|
actions.setVersionsLoading(false)
|
|
}
|
|
},
|
|
|
|
loadFileContents: async () => {
|
|
const skill = values.skill
|
|
if (!isSkill(skill) || !skill.files || skill.files.length === 0) {
|
|
actions.setFileContentsLoading(false)
|
|
return
|
|
}
|
|
try {
|
|
const fileContents = await fetchAllFileContents(props.skillName, skill)
|
|
actions.setSkillFormValues({
|
|
files: fileContents.map((f) => ({
|
|
path: f.path,
|
|
content: f.content,
|
|
content_type: f.content_type || 'text/plain',
|
|
})),
|
|
})
|
|
} catch (e) {
|
|
console.error('Failed to load file contents for editing', e)
|
|
} finally {
|
|
actions.setFileContentsLoading(false)
|
|
}
|
|
},
|
|
|
|
setMode: ({ mode }) => {
|
|
if (
|
|
mode === SkillMode.Edit &&
|
|
isSkill(values.skill) &&
|
|
values.skill.files &&
|
|
values.skill.files.length > 0
|
|
) {
|
|
actions.loadFileContents()
|
|
}
|
|
},
|
|
|
|
loadSkillSuccess: ({ skill }) => {
|
|
if (skill && isSkill(skill)) {
|
|
actions.resetSkillForm()
|
|
actions.setSkillFormValues(getSkillFormDefaults(skill))
|
|
}
|
|
},
|
|
|
|
setCompareVersion: ({ compareVersion }) => {
|
|
if (compareVersion !== null) {
|
|
actions.loadCompareSkill(compareVersion)
|
|
}
|
|
},
|
|
|
|
loadCompareSkillFailure: () => {
|
|
toast.error('Failed to load comparison version')
|
|
},
|
|
})),
|
|
|
|
defaults(
|
|
({
|
|
props,
|
|
}): {
|
|
skill: SkillFormValues | ResolvedLLMSkill | null
|
|
skillForm: SkillFormValues
|
|
versionsLoading: boolean
|
|
} => {
|
|
if (props.skillName === 'new') {
|
|
return {
|
|
skill: DEFAULT_SKILL_FORM_VALUES,
|
|
skillForm: DEFAULT_SKILL_FORM_VALUES,
|
|
versionsLoading: false,
|
|
}
|
|
}
|
|
|
|
const existingSkill = findExistingSkill(props.skillName)
|
|
|
|
if (existingSkill) {
|
|
// The list endpoint omits body and files for progressive disclosure; pad
|
|
// them so the cached preview satisfies the full shape until loadSkill runs.
|
|
const paddedSkill: ResolvedLLMSkill = {
|
|
...existingSkill,
|
|
body: '',
|
|
body_total_length: 0,
|
|
body_next_offset: null,
|
|
files: [],
|
|
versions: [],
|
|
has_more: false,
|
|
}
|
|
return {
|
|
skill: paddedSkill,
|
|
skillForm: getSkillFormDefaults(paddedSkill),
|
|
versionsLoading: false,
|
|
}
|
|
}
|
|
|
|
return {
|
|
skill: null,
|
|
skillForm: DEFAULT_SKILL_FORM_VALUES,
|
|
versionsLoading: false,
|
|
}
|
|
}
|
|
),
|
|
|
|
afterMount(({ actions, values }) => {
|
|
if (values.isNewSkill) {
|
|
actions.setSkill(DEFAULT_SKILL_FORM_VALUES)
|
|
actions.resetSkillForm(DEFAULT_SKILL_FORM_VALUES)
|
|
} else {
|
|
actions.loadSkill()
|
|
}
|
|
}),
|
|
|
|
urlToAction(({ actions, values }) => ({
|
|
'/skills/:name': (_, __, ___, { method }) => {
|
|
if (method === 'PUSH' && values.isNewSkill) {
|
|
actions.setSkill(DEFAULT_SKILL_FORM_VALUES)
|
|
actions.resetSkillForm(DEFAULT_SKILL_FORM_VALUES)
|
|
return
|
|
}
|
|
|
|
if (method === 'PUSH' && !values.isNewSkill) {
|
|
actions.loadSkill()
|
|
}
|
|
},
|
|
})),
|
|
])
|