diff --git a/Makefile b/Makefile index 6201313e..dc4c552d 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ beaker-vue/dist:$(call npm_build_deps,beaker-vue) touch beaker-vue/dist beaker-vue/html:$(call npm_build_deps,beaker-vue) - (cd beaker-vue && npm run build-ui) && \ + (cd beaker-vue && npm run build-ui && npm run routes) && \ touch beaker-vue/html src/beaker_notebook/app/ui/index.html:beaker-vue/node_modules beaker-vue/html diff --git a/beaker-vue/package.json b/beaker-vue/package.json index 538c5c72..94312d3b 100644 --- a/beaker-vue/package.json +++ b/beaker-vue/package.json @@ -66,6 +66,7 @@ "content-disposition": "^0.5.4", "cookie": "^1.0.2", "cytoscape": "^3.31.2", + "dompurify": "^3.4.14", "escape-html": "^1.0.3", "fflate": "^0.8.3", "filesize": "^10.1.6", diff --git a/beaker-vue/src/__tests__/skillMarkdown.spec.ts b/beaker-vue/src/__tests__/skillMarkdown.spec.ts new file mode 100644 index 00000000..78dcbb53 --- /dev/null +++ b/beaker-vue/src/__tests__/skillMarkdown.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { + isRelativeHref, + resolveResourceFromHref, + type Integration, +} from '../util/integration'; +import { renderMarkdown } from '../util/markdown'; + +const integration = { + resources: { + 'r1': { resource_id: 'r1', resource_type: 'skill_file', relative_path: 'references/FILTERS.md' }, + 'r2': { resource_id: 'r2', resource_type: 'skill_file', relative_path: 'references/CROSS-REPOSITORY.md' }, + 'r3': { resource_id: 'r3', resource_type: 'skill_file', relative_path: 'assets/service_openapi.yaml' }, + 'r4': { resource_id: 'r4', resource_type: 'skill_file', relative_path: 'references/my notes.md' }, + 'e1': { resource_id: 'e1', resource_type: 'skill_example', filename: 'find_cohort.md' }, + 'i1': { resource_id: 'i1', resource_type: 'skill_instructions', content: '# hi' }, + }, +} as unknown as Integration; + +describe('isRelativeHref', () => { + it('accepts relative paths', () => { + expect(isRelativeHref('references/FILTERS.md')).toBe(true); + expect(isRelativeHref('./FILTERS.md')).toBe(true); + expect(isRelativeHref('../assets/service_openapi.yaml')).toBe(true); + }); + + it('rejects external, protocol-relative, mailto, and in-page links', () => { + expect(isRelativeHref('https://example.com/x.md')).toBe(false); + expect(isRelativeHref('http://example.com')).toBe(false); + expect(isRelativeHref('//example.com/x.md')).toBe(false); + expect(isRelativeHref('mailto:someone@example.com')).toBe(false); + expect(isRelativeHref('#section')).toBe(false); + expect(isRelativeHref('')).toBe(false); + }); +}); + +describe('resolveResourceFromHref', () => { + it('resolves a skill-root link to a file resource', () => { + expect(resolveResourceFromHref(integration, 'references/FILTERS.md')?.resource_id).toBe('r1'); + }); + + it('resolves an examples/ link to an example resource', () => { + expect(resolveResourceFromHref(integration, 'examples/find_cohort.md')?.resource_id).toBe('e1'); + }); + + it('resolves sibling links against the linking file directory', () => { + expect(resolveResourceFromHref(integration, 'CROSS-REPOSITORY.md', 'references')?.resource_id).toBe('r2'); + }); + + it('resolves ../ traversal', () => { + expect(resolveResourceFromHref(integration, '../assets/service_openapi.yaml', 'references')?.resource_id).toBe('r3'); + }); + + it('falls back to the skill root when a base-relative link does not resolve', () => { + // Example files commonly use root-relative paths like SKILL.md does. + expect(resolveResourceFromHref(integration, 'references/FILTERS.md', 'examples')?.resource_id).toBe('r1'); + }); + + it('percent-decodes encoded hrefs', () => { + expect(resolveResourceFromHref(integration, 'references/my%20notes.md')?.resource_id).toBe('r4'); + }); + + it('ignores ./ segments, query strings, and fragments', () => { + expect(resolveResourceFromHref(integration, './references/FILTERS.md')?.resource_id).toBe('r1'); + expect(resolveResourceFromHref(integration, 'references/FILTERS.md#operators')?.resource_id).toBe('r1'); + expect(resolveResourceFromHref(integration, 'references/FILTERS.md?x=1')?.resource_id).toBe('r1'); + }); + + it('returns undefined for unknown paths and empty hrefs', () => { + expect(resolveResourceFromHref(integration, 'auth.yaml')).toBeUndefined(); + expect(resolveResourceFromHref(integration, '')).toBeUndefined(); + expect(resolveResourceFromHref(undefined, 'references/FILTERS.md')).toBeUndefined(); + }); +}); + +describe('renderMarkdown', () => { + it('renders markdown to HTML', () => { + const html = renderMarkdown('# Title\n\nSome **bold** text.'); + expect(html).toContain('

'); + expect(html).toContain('bold'); + }); + + it('strips script tags and event handlers from embedded HTML', () => { + expect(renderMarkdown('hello ')).not.toContain(''); + expect(html).not.toContain('onerror'); + }); + + it('neutralizes javascript: links but keeps normal ones', () => { + expect(renderMarkdown('[x](javascript:alert(1))')).not.toContain('javascript:'); + expect(renderMarkdown('[x](https://example.com)')).toContain('href="https://example.com"'); + expect(renderMarkdown('[x](references/FILTERS.md)')).toContain('href="references/FILTERS.md"'); + }); + + it('returns an empty string for empty input', () => { + expect(renderMarkdown('')).toBe(''); + expect(renderMarkdown(undefined)).toBe(''); + expect(renderMarkdown(null)).toBe(''); + }); +}); diff --git a/beaker-vue/src/components/integrations/ExamplesPanel.vue b/beaker-vue/src/components/integrations/ExamplesPanel.vue deleted file mode 100644 index 0ea8b0d2..00000000 --- a/beaker-vue/src/components/integrations/ExamplesPanel.vue +++ /dev/null @@ -1,429 +0,0 @@ - - - - - - diff --git a/beaker-vue/src/components/integrations/IntegrationPanel.vue b/beaker-vue/src/components/integrations/IntegrationPanel.vue index 2084a13f..656d0396 100644 --- a/beaker-vue/src/components/integrations/IntegrationPanel.vue +++ b/beaker-vue/src/components/integrations/IntegrationPanel.vue @@ -80,7 +80,7 @@ >
const renderIntegrations = (integrations: Integration[]) => integrations.map(integration => - ({...integration, description: marked.parse(integration?.description ?? "") as string})) + ({...integration, description: renderMarkdown(integration?.description)})) -const processIntegrations = (integrations: Integration[]) => - renderIntegrations(filterIntegrations(sortIntegrations(integrations))) +// Markdown parsing + sanitization is the expensive step, so cache it keyed on +// the integration data; the cheap search filter recomputes per keystroke on +// top of the cached result instead of re-rendering every description (which +// an inline template call would also do on every hover-state change). +const renderedIntegrations = computed(() => + renderIntegrations(sortIntegrations(Object.values(integrations.value ?? {})))); + +const displayIntegrations = computed(() => + filterIntegrations(renderedIntegrations.value)); // const relevantProviders = (providers: IntegrationProviders): IntegrationProviders => // Object.keys(providers) diff --git a/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue b/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue index 6172cb2b..88cc3f04 100644 --- a/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue +++ b/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue @@ -323,7 +323,7 @@ import Checkbox from 'primevue/checkbox'; import Button from 'primevue/button'; import ProgressSpinner from 'primevue/progressspinner'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; import CodeEditor from '../misc/CodeEditor.vue'; @@ -434,7 +434,7 @@ watch(() => selectedIntegration.value?.description, (current) => { // read-only here (see the Instructions fieldset). Not persisted to config. const renderedInstructions = computed(() => { const instructions = selectedIntegration.value?.instructions; - return instructions ? marked.parse(instructions) as string : ""; + return renderMarkdown(instructions); }); const hasServerInfo = computed(() => diff --git a/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue b/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue index 941345ca..d64ef7c6 100644 --- a/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue +++ b/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue @@ -160,7 +160,7 @@ import Fieldset from 'primevue/fieldset'; import InputText from 'primevue/inputtext'; import ProgressSpinner from 'primevue/progressspinner'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; const props = defineProps<{ fetchResources: () => Promise, @@ -199,11 +199,11 @@ const serverConfig = computed(() => selectedIntegration.value?.server_config); const renderedDescription = computed(() => - marked.parse(selectedIntegration.value?.description ?? "") as string); + renderMarkdown(selectedIntegration.value?.description)); const renderedInstructions = computed(() => { const instructions = selectedIntegration.value?.instructions; - return instructions ? marked.parse(instructions) as string : ""; + return renderMarkdown(instructions); }); const hasServerInfo = computed(() => diff --git a/beaker-vue/src/components/integrations/MCPToolsPanel.vue b/beaker-vue/src/components/integrations/MCPToolsPanel.vue index 0bbd3d3f..c1504526 100644 --- a/beaker-vue/src/components/integrations/MCPToolsPanel.vue +++ b/beaker-vue/src/components/integrations/MCPToolsPanel.vue @@ -124,7 +124,7 @@ import Tag from 'primevue/tag'; import InputGroup from "primevue/inputgroup"; import InputGroupAddon from "primevue/inputgroupaddon"; import InputText from "primevue/inputtext"; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; import { type IntegrationInterfaceState, type MCPToolResource, @@ -209,7 +209,7 @@ const focusedArgs = computed(() => const renderedDescription = computed(() => { if (viewState.value.view !== 'focused') return ""; const description = viewState.value.tool.description; - return description ? marked.parse(description) as string : ""; + return renderMarkdown(description); }); const viewTool = (tool: MCPToolResource) => { diff --git a/beaker-vue/src/components/integrations/ResourceViewer.vue b/beaker-vue/src/components/integrations/ResourceViewer.vue deleted file mode 100644 index 62a09e97..00000000 --- a/beaker-vue/src/components/integrations/ResourceViewer.vue +++ /dev/null @@ -1,309 +0,0 @@ - - - - - - diff --git a/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue b/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue index 915b6066..75b013c2 100644 --- a/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue +++ b/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue @@ -110,8 +110,19 @@
-

The skill's full instructions, disclosed to the agent when it loads the skill.

-
+
+

The skill's full instructions, disclosed to the agent when it loads the skill.

+
+
-
+
+ +

@@ -159,7 +176,10 @@ import { type SkillIntegration, type SkillMetadataResource, type SkillInstructionsResource, + type SkillFileResource, + type SkillExampleResource, isContextProvidedIntegration, + resourceFromLinkClick, filterByResourceType, previewRemoteSkill, } from '../../util/integration'; @@ -170,9 +190,11 @@ import InputChips from 'primevue/inputchips'; import Select from 'primevue/select'; import Button from 'primevue/button'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; import CodeEditor from '../misc/CodeEditor.vue'; +import ClampedMarkdown from '../misc/ClampedMarkdown.vue'; +import SkillResourceLinks from './SkillResourceLinks.vue'; const showToast = inject('show_toast'); @@ -187,6 +209,10 @@ const props = defineProps<{ const model = defineModel(); +const emit = defineEmits<{ + (e: 'open-resource', resourceId: string): void, +}>(); + const selectedIntegration = computed(() => model.value.integrations[model.value.selected] as SkillIntegration); @@ -255,7 +281,7 @@ const syncFromIntegration = () => { allowedToolsList.value = (metadata?.allowed_tools ?? '') .split(',').map((tool) => tool.trim()).filter((tool) => tool !== ''); metadataRows.value = Object.entries(metadata?.skill_metadata ?? {}) - .map(([key, value]) => ({ key, value: String(value) })); + .map(([key, value]) => ({ key, value: value == null ? '' : String(value) })); }; const markDirty = () => { @@ -301,11 +327,31 @@ const fetchFromUrl = async () => { }; const renderedInstructions = computed(() => - instructions.value ? marked.parse(instructions.value) as string : ""); + renderMarkdown(instructions.value)); + +// Instructions default to a rendered preview; Edit toggles the raw editor. +// New/empty skills start in the editor since there is nothing to preview. +const showInstructionsRendered = ref(true); + +const onInstructionsLinkClick = (event: MouseEvent) => { + const resource = resourceFromLinkClick(event, selectedIntegration.value); + if (resource) { + emit('open-resource', resource.resource_id); + } +}; + +const fileResources = computed(() => + Object.values(filterByResourceType( + selectedIntegration.value?.resources, "skill_file"))); + +const exampleResources = computed(() => + Object.values(filterByResourceType( + selectedIntegration.value?.resources, "skill_example"))); watch(() => model.value.selected, () => { remotePreviewed.value = false; syncFromIntegration(); + showInstructionsRendered.value = instructions.value !== ''; // A freshly created skill arrives pre-dirtied so its Save button shows // immediately; only clear the flag when landing on an existing one. if (!isNew.value) { @@ -317,7 +363,14 @@ watch(() => model.value.selected, () => { // save re-fetches the authoritative copy), unless the user has edits pending. watch(() => selectedIntegration.value?.resources, () => { if (!model.value.unsavedChanges) { + // Flip to the rendered preview only when instructions first arrive + // into an empty editor (the initial fetch resolving after selection); + // never yank the user out of an editor they are already using. + const wasEmpty = instructions.value === ''; syncFromIntegration(); + if (wasEmpty && instructions.value !== '') { + showInstructionsRendered.value = true; + } } }); @@ -502,12 +555,14 @@ const remove = async () => { flex-direction: column; } -.skill-description { - h1 { font-size: 1.25rem; margin-bottom: 1rem; } - h2 { font-size: 1.2rem; margin-bottom: 0.8rem; } - h3 { font-size: 1.15rem; margin-bottom: 0.8rem; } - p, ul, li { margin-bottom: 0.8rem; margin-top: 0rem; } - > *:first-child { margin-top: 0rem; } +.skill-instructions-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + + p { margin: 0 0 0.8rem 0; } + button { flex-shrink: 0; } } .skill-readonly-note { diff --git a/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue b/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue index 493787e5..c0c69fd3 100644 --- a/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue +++ b/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue @@ -38,40 +38,15 @@

-
-

- These resources are available to the agent and will be loaded on demand when the skill is active. -

-
-
- - {{ resource.relative_path }} -
-
+
+
-
-

- Code examples demonstrating usage patterns for this skill. -

-
-
- -
- {{ example.filename }} - {{ example.title }} -
-
-
-
+ @@ -83,29 +58,49 @@ import { computed } from 'vue'; import { type Integration, type IntegrationInterfaceState, - type IntegrationResource, type SkillMetadataResource, + type SkillInstructionsResource, type SkillFileResource, type SkillExampleResource, filterByResourceType, + resourceFromLinkClick, } from '../../util/integration'; import Fieldset from 'primevue/fieldset'; import InputText from 'primevue/inputtext'; +import ClampedMarkdown from '../misc/ClampedMarkdown.vue'; +import SkillResourceLinks from './SkillResourceLinks.vue'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; const props = defineProps<{ fetchResources: () => Promise, }>(); +const emit = defineEmits<{ + (e: 'open-resource', resourceId: string): void, +}>(); + const model = defineModel(); const selectedIntegration = computed(() => model.value.integrations[model.value.selected]); const renderedDescription = computed(() => - marked.parse(selectedIntegration.value?.description ?? "") as string); + renderMarkdown(selectedIntegration.value?.description)); + +const renderedInstructions = computed(() => { + const instructions = Object.values(filterByResourceType( + selectedIntegration.value?.resources, "skill_instructions"))[0]; + return renderMarkdown(instructions?.content); +}); + +const onInstructionsLinkClick = (event: MouseEvent) => { + const resource = resourceFromLinkClick(event, selectedIntegration.value); + if (resource) { + emit('open-resource', resource.resource_id); + } +}; const metadata = computed(() => { const resources = filterByResourceType( @@ -150,14 +145,6 @@ const exampleResources = computed(() => { } } -.skill-description { - h1 { font-size: 1.25rem; margin-bottom: 1rem; } - h2 { font-size: 1.2rem; margin-bottom: 0.8rem; } - h3 { font-size: 1.15rem; margin-bottom: 0.8rem; } - p, ul, li { margin-bottom: 0.8rem; margin-top: 0rem; } - > *:first-child { margin-top: 0rem; } -} - .skill-metadata-grid { display: flex; flex-direction: column; @@ -184,38 +171,4 @@ const exampleResources = computed(() => { .skill-no-metadata { color: var(--p-text-muted-color); } - -.skill-resource-list { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.skill-resource-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.35rem 0.5rem; - border-radius: 4px; - font-size: 0.9rem; - - &:hover { - background-color: var(--p-surface-100); - } -} - -.skill-resource-path { - font-family: monospace; -} - -.skill-example-info { - display: flex; - flex-direction: column; - gap: 0.15rem; -} - -.skill-example-title { - font-size: 0.85rem; - color: var(--p-text-muted-color); -} diff --git a/beaker-vue/src/components/integrations/SkillResourceLinks.vue b/beaker-vue/src/components/integrations/SkillResourceLinks.vue new file mode 100644 index 00000000..69b59ff4 --- /dev/null +++ b/beaker-vue/src/components/integrations/SkillResourceLinks.vue @@ -0,0 +1,108 @@ + + + + + + + diff --git a/beaker-vue/src/components/integrations/SkillResourcePanel.vue b/beaker-vue/src/components/integrations/SkillResourcePanel.vue index 9bfc137b..c34c254d 100644 --- a/beaker-vue/src/components/integrations/SkillResourcePanel.vue +++ b/beaker-vue/src/components/integrations/SkillResourcePanel.vue @@ -79,8 +79,18 @@