diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index cc78a346a..6e3bbbf2a 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -11,6 +11,7 @@ import { botUsage, costCaption, formatTokens, formatUsd, hasFiniteCost } from "@ import { shortPath } from "@/lib/short-path"; import { instanceSupportsLocalComputer, localComputerDisabledReason, localComputerSelectable } from "@/lib/local-computer"; import { BotProfileAvatarCard } from "./BotProfileAvatarCard"; +import { SkillsCard } from "./SkillsCard"; import { LocalComputerAutoWarning } from "./LocalComputerAutoWarning"; import { VoiceSettings } from "./VoiceSettings"; import { BOT_PROFILE_LIMITS } from "../../shared/bot-profile"; @@ -639,6 +640,9 @@ export function SettingsPanel({ bot }: { bot: Bot }) { {/* keyed so switching bots never shows one bot's notes under another's name */} + {/* same reason: a review pane open for one bot must not survive a switch */} + +
Auto mode
diff --git a/src/components/SkillsCard.test.ts b/src/components/SkillsCard.test.ts new file mode 100644 index 000000000..376a59210 --- /dev/null +++ b/src/components/SkillsCard.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + mergeInstalled, + provenanceLine, + removeSkillConfirmation, + warningBadgeLabel, + type BotSkill, +} from "./SkillsCard"; + +const skill = (name: string, overrides: Partial = {}): BotSkill => ({ + name, + description: `${name} description`, + enabled: false, + source: `github.com/example/${name}`, + sha256: "0123456789abcdef".repeat(4), + importedAt: "2026-08-24T00:00:00.000Z", + warnings: [], + skippedFiles: [], + ...overrides, +}); + +describe("imported-skill list helpers", () => { + it("shows the source with only the first 8 characters of the hash", () => { + expect(provenanceLine(skill("pdf-tools"))).toBe("github.com/example/pdf-tools · 01234567"); + }); + + it("folds fresh imports in sorted by name, replacing a re-import's stale row", () => { + const existing = [skill("alpha"), skill("delta", { warnings: ["old warning"] })]; + const merged = mergeInstalled(existing, [skill("charlie"), skill("delta")]); + + expect(merged.map((entry) => entry.name)).toEqual(["alpha", "charlie", "delta"]); + // the re-imported row carries the NEW scan result, not the remembered one + expect(merged[2]?.warnings).toEqual([]); + }); + + it("keeps a merge with no fresh imports identical to the existing list", () => { + const existing = [skill("alpha"), skill("bravo")]; + expect(mergeInstalled(existing, [])).toEqual(existing); + }); + + it("pluralizes the warning badge", () => { + expect(warningBadgeLabel(1)).toBe("1 warning"); + expect(warningBadgeLabel(3)).toBe("3 warnings"); + }); + + it("names the exact skill in the remove confirmation", () => { + expect(removeSkillConfirmation("pdf-tools")).toBe( + "Remove the imported skill “pdf-tools”? Its files are deleted from this bot's workspace.", + ); + }); +}); diff --git a/src/components/SkillsCard.tsx b/src/components/SkillsCard.tsx new file mode 100644 index 000000000..410455ec5 --- /dev/null +++ b/src/components/SkillsCard.tsx @@ -0,0 +1,335 @@ +// Imported Agent Skills, per bot — the review gate over server/skills.ts. +// +// The server imports skills DISABLED and records provenance plus scan +// warnings; this card is where a person reads the exact SKILL.md text and +// those warnings, then enables. Two rules the layout enforces: +// - imported content renders as PLAIN TEXT, never markdown. A skill is +// untrusted input, and a markdown renderer is exactly the surface a +// malicious import would style itself for — what you read here is +// byte-for-byte what the bot will read. +// - enabling is a button a person presses with the text on screen. +// Nothing enables as a side effect of importing. +import { AlertTriangle, ChevronDown, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { api, type Bot } from "@/state/store"; +import { cn } from "@/lib/cn"; + +/** Mirror of the server's SkillListing (server/skills.ts). */ +export interface BotSkill { + name: string; + description: string; + enabled: boolean; + source: string; + sha256: string; + importedAt: string; + license?: string; + compatibility?: string; + warnings: string[]; + skippedFiles: string[]; +} + +/** One line answering "where did this come from": the source as imported plus + * enough of the content hash to compare against a fresh fetch. */ +export function provenanceLine(skill: Pick): string { + return `${skill.source} · ${skill.sha256.slice(0, 8)}`; +} + +export function warningBadgeLabel(count: number): string { + return count === 1 ? "1 warning" : `${count} warnings`; +} + +/** Fold a POST's installed skills into the list the GET produced: replace by + * name (a re-import must show its new scan results, not the stale row) and + * keep the server's name sort so rows never jump on the next refresh. */ +export function mergeInstalled(existing: BotSkill[], installed: BotSkill[]): BotSkill[] { + const byName = new Map(existing.map((skill) => [skill.name, skill] as const)); + for (const skill of installed) byName.set(skill.name, skill); + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function removeSkillConfirmation(name: string): string { + return `Remove the imported skill “${name}”? Its files are deleted from this bot's workspace.`; +} + +const inputCls = + "w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2.5 text-[15px] text-ink placeholder:text-ink-secondary focus:outline-none focus:border-hairline"; + +interface Review { + name: string; + /** null while the SKILL.md fetch is in flight */ + text: string | null; +} + +/** The Skills card in a bot's settings. Fetched on expand, like MemoryCard: + * settings opens for every bot and most visits never look at skills. */ +export function SkillsCard({ bot }: { bot: Bot }) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [skills, setSkills] = useState([]); + const [source, setSource] = useState(""); + const [importing, setImporting] = useState(false); + const [importErrors, setImportErrors] = useState([]); + const [review, setReview] = useState(null); + /** name of the skill an enable/disable/remove call is in flight for */ + const [busy, setBusy] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + setReview(null); + try { + const result: { skills: BotSkill[] } = await api(`/api/bots/${bot.id}/skills`); + setSkills(result.skills); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + const openReview = async (name: string) => { + if (review?.name === name) { + setReview(null); + return; + } + setError(null); + setReview({ name, text: null }); + try { + const result: { text: string } = await api(`/api/bots/${bot.id}/skills/${name}`); + setReview((current) => (current?.name === name ? { name, text: result.text } : current)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setReview((current) => (current?.name === name ? null : current)); + } + }; + + const importSkills = async (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = source.trim(); + if (!trimmed || importing) return; + setImporting(true); + setError(null); + setImportErrors([]); + try { + const result: { installed: BotSkill[]; errors: string[] } = await api(`/api/bots/${bot.id}/skills`, { + method: "POST", + body: JSON.stringify({ source: trimmed }), + }); + setSkills((current) => mergeInstalled(current, result.installed)); + setImportErrors(result.errors); + setSource(""); + // the review gate is the point: put the first import's SKILL.md and + // warnings on screen right away, with Enable at the end of the read + const first = result.installed[0]; + if (first) void openReview(first.name); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setImporting(false); + } + }; + + const setEnabled = async (name: string, enabled: boolean) => { + setBusy(name); + setError(null); + try { + const result: { skill: BotSkill } = await api(`/api/bots/${bot.id}/skills/${name}`, { + method: "PATCH", + body: JSON.stringify({ enabled }), + }); + setSkills((current) => current.map((skill) => (skill.name === name ? result.skill : skill))); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(null); + } + }; + + const remove = async (name: string) => { + if (!window.confirm(removeSkillConfirmation(name))) return; + setBusy(name); + setError(null); + try { + await api(`/api/bots/${bot.id}/skills/${name}`, { method: "DELETE" }); + setSkills((current) => current.filter((skill) => skill.name !== name)); + setReview((current) => (current?.name === name ? null : current)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(null); + } + }; + + return ( +
+ + + {open && loading &&
Loading…
} + + {open && !loading && ( +
+ {skills.length === 0 ? ( +
+ No skills imported yet — paste a GitHub repo that holds a SKILL.md to teach this bot something new. +
+ ) : ( +
+ {skills.map((skill) => { + const reviewing = review !== null && review.name === skill.name ? review : null; + return ( +
+
+
+
+ {skill.name} + {skill.warnings.length > 0 && ( + + + {warningBadgeLabel(skill.warnings.length)} + + )} +
+
{skill.description}
+
+ + {provenanceLine(skill)} + + +
+
+ + +
+ + {reviewing && ( +
+ {skill.warnings.length > 0 && ( +
+
+ Read these before enabling +
+
    + {skill.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ )} + {skill.skippedFiles.length > 0 && ( +
0 && "mt-2")}> + Skipped at import (markdown only):{" "} + {skill.skippedFiles.join(", ")} +
+ )} + {(skill.license || skill.compatibility) && ( +
+ {[ + skill.license && `License: ${skill.license}`, + skill.compatibility && `Compatibility: ${skill.compatibility}`, + ] + .filter(Boolean) + .join(" · ")} +
+ )} + {/* Plain
, never markdown: see the header comment. */}
+                        
+                          {reviewing.text ?? "Loading…"}
+                        
+ {!skill.enabled && ( + + )} +
+ )} +
+ ); + })} +
+ )} + +
void importSkills(e)}> + setSource(e.target.value)} + /> + +
+ {importErrors.length > 0 && ( +
+ {importErrors.map((message) => ( +
{message}
+ ))} +
+ )} +
+ )} + + {error &&
{error}
} +
+ ); +}