Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,150 @@
"view": "slidev-slides-tree",
"contents": "No active slides entry.\n[Choose one](command:slidev.choose-entry)"
}
],
"languageModelTools": [
{
"name": "slidev_getActiveSlide",
"tags": [
"slidev"
],
"toolReferenceName": "getActiveSlide",
"displayName": "Get Active Slide",
"modelDescription": "Get the information of the active slide the user is currently focused on in a Slidev presentation.",
"userDescription": "Get the information of the active slide in a Slidev presentation.",
"canBeReferencedInPrompt": true,
"icon": "$(debug-stackframe-active)",
"inputSchema": {
"type": "object",
"properties": {
}
}
},
{
"name": "slidev_getSlideContent",
"tags": [
"slidev"
],
"toolReferenceName": "getSlideContent",
"displayName": "Get Slide Content",
"modelDescription": "Get the content of a specific slide in a Slidev presentation by providing the slide number.",
"userDescription": "Get the content of a specific slide in a Slidev presentation by providing the slide number.",
"canBeReferencedInPrompt": true,
"icon": "$(file-code)",
"inputSchema": {
"type": "object",
"properties": {
"entrySlidePath": {
"type": "string",
"description": "The path to the Slidev entry file (e.g., `./slides.md`). Empty string means the active slide entry.",
"default": "$ACTIVE_SLIDE_ENTRY"
},
"slideNo": {
"type": "number",
"description": "The slide number to retrieve content from. Starts from 1. Hidden slides are not counted."
}
}
}
},
{
"name": "slidev_getAllSlideTitles",
"tags": ["slidev"],
"toolReferenceName": "getAllSlideTitles",
"displayName": "Get All Slide Titles",
"modelDescription": "Get the list of all slide titles in the specified Slidev project.",
"userDescription": "Get the list of all slide titles in the specified Slidev project.",
"canBeReferencedInPrompt": true,
"icon": "$(list-unordered)",
"inputSchema": {
"type": "object",
"properties": {
"entrySlidePath": {
"type": "string",
"description": "The path to the Slidev entry file (e.g., ./slides.md). Empty string means the active slide entry.",
"default": "$ACTIVE_SLIDE_ENTRY"
}
}
}
},
{
"name": "slidev_findSlideNoByTitle",
"tags": ["slidev"],
"toolReferenceName": "findSlideNoByTitle",
"displayName": "Find Slide Number by Title",
"modelDescription": "Find the slide number in the specified Slidev project by its title.",
"userDescription": "Find the slide number in the specified Slidev project by its title.",
"canBeReferencedInPrompt": true,
"icon": "$(search)",
"inputSchema": {
"type": "object",
"properties": {
"entrySlidePath": {
"type": "string",
"description": "The path to the Slidev entry file (e.g., ./slides.md). Empty string means the active slide entry.",
"default": "$ACTIVE_SLIDE_ENTRY"
},
"title": {
"type": "string",
"description": "The title of the slide to search for."
}
}
}
},
{
"name": "slidev_listEntries",
"tags": ["slidev"],
"toolReferenceName": "listEntries",
"displayName": "List All Loaded Slidev Entries",
"modelDescription": "Get all loaded Slidev project entry file paths.",
"userDescription": "Get all loaded Slidev project entry file paths.",
"canBeReferencedInPrompt": true,
"icon": "$(file-directory)",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "slidev_getPreviewPort",
"tags": ["slidev"],
"toolReferenceName": "getPreviewPort",
"displayName": "Get Project Preview Port",
"modelDescription": "Get the preview port number of the specified Slidev project.",
"userDescription": "Get the preview port number of the specified Slidev project.",
"canBeReferencedInPrompt": true,
"icon": "$(plug)",
"inputSchema": {
"type": "object",
"properties": {
"entrySlidePath": {
"type": "string",
"description": "The path to the Slidev entry file (e.g., ./slides.md). Empty string means the active slide entry.",
"default": "$ACTIVE_SLIDE_ENTRY"
}
}
}
},
{
"name": "slidev_chooseEntry",
"tags": ["slidev"],
"toolReferenceName": "chooseEntry",
"displayName": "Choose Active Slidev Entry",
"modelDescription": "Switch the active Slidev project entry to the specified entry file path.",
"userDescription": "Switch the active Slidev project entry to the specified entry file path.",
"canBeReferencedInPrompt": true,
"icon": "$(arrow-swap)",
"inputSchema": {
"type": "object",
"properties": {
"entrySlidePath": {
"type": "string",
"description": "The path to the Slidev entry file to activate (e.g., ./slides.md).",
"default": ""
}
},
"required": ["entrySlidePath"]
}
}
]
},
"scripts": {
Expand Down
4 changes: 4 additions & 0 deletions packages/vscode/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineExtension } from 'reactive-vscode'
import { useCommands } from './commands'
import { useLanguageClient } from './languageClient'
import { useLmTools } from './lmTools'
import { activeEntry, useProjects } from './projects'
import { useAnnotations } from './views/annotations'
import { useFoldings } from './views/foldings'
Expand All @@ -26,6 +27,9 @@ const { activate, deactivate } = defineExtension(() => {
// language server
const labsInfo = useLanguageClient()

// language model tools
useLmTools()

logger.info('Slidev activated.')
logger.info(`Entry: ${activeEntry.value}`)

Expand Down
152 changes: 152 additions & 0 deletions packages/vscode/src/lmTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { slash } from '@antfu/utils'
import { stringifySlide } from '@slidev/parser/core'
import { createSingletonComposable, useDisposable } from 'reactive-vscode'
import { LanguageModelTextPart, LanguageModelToolResult, lm } from 'vscode'
import { useEditingSlideSource } from './composables/useEditingSlideSource'
import { useFocusedSlideNo } from './composables/useFocusedSlideNo'
import { activeEntry, activeProject, projects } from './projects'

export const useLmTools = createSingletonComposable(() => {
const focusedSlideNo = useFocusedSlideNo()
const editingSlide = useEditingSlideSource()

registerSimpleTool('slidev_getActiveSlide', () => {
const project = activeProject.value

if (project == null) {
throw new Error(`No active slide project found.`)
}

return formatObject({
'Entry file': project.entry,
'Root directory': project.userRoot,
'Preview server port': project.port || 'Not running',
'Number of slides': project.data.slides.length,
'Focused slide no. in presentation (from 1)': focusedSlideNo.value,
'Editing file': editingSlide.markdown.value?.filepath || 'Not editing',
'Editing slide index in file (from 0)': editingSlide.index.value,
})
})

registerSimpleTool('slidev_getSlideContent', (input: {
entrySlidePath: string
slideNo: number
}) => {
const project = resolveProjectFromEntry(input.entrySlidePath)
const slide = project.data.slides[input.slideNo - 1]

if (slide == null) {
throw new Error(`No content found for slide number ${input.slideNo} in entry: ${project.entry}. Available slides numbers: 1-${project.data.slides.length}`)
}

return `Content of slide number ${input.slideNo} in entry "${project.entry}" in file "${slide.source.filepath}":\n\n${stringifySlide(slide.source, 1)}`
})

// Get all slide titles
registerSimpleTool('slidev_getAllSlideTitles', (input: { entrySlidePath: string }) => {
const project = resolveProjectFromEntry(input.entrySlidePath)
const titles = project.data.slides.map((slide, idx) => `#${idx + 1}: ${slide.title || '(Untitled)'}`)
return formatList(titles)
})

// Find slide number by title
registerSimpleTool('slidev_findSlideNoByTitle', (input: { entrySlidePath: string, title: string }) => {
const project = resolveProjectFromEntry(input.entrySlidePath)
const idx = project.data.slides.findIndex(slide => slide.title === input.title)
if (idx === -1) {
throw new Error(`No slide found with title: "${input.title}".`)
}
return formatObject({
'Title': input.title,
'Slide number': idx + 1,
})
})

// List all loaded Slidev entries
registerSimpleTool('slidev_listEntries', () => {
const entries = [...projects.keys()]
if (entries.length === 0) {
return 'No loaded Slidev project entries.'
}
return formatList(entries)
})

// Get project preview port
registerSimpleTool('slidev_getPreviewPort', (input: { entrySlidePath: string }) => {
const project = resolveProjectFromEntry(input.entrySlidePath)
return formatObject({
'Project entry': project.entry,
'Preview port': project.port || 'Not running',
})
})

// Choose active Slidev entry
registerSimpleTool('slidev_chooseEntry', (input: { entrySlidePath: string }) => {
if (!input.entrySlidePath) {
throw new Error('entrySlidePath is required.')
}
const project = resolveProjectFromEntry(input.entrySlidePath)
activeEntry.value = project.entry
return formatObject({
'Active entry switched to': project.entry,
})
})
})

function registerSimpleTool<T>(name: string, invoke: (input: T) => string) {
useDisposable(lm.registerTool<T>(name, {
invoke({ input }) {
try {
const result = invoke(input)
return new LanguageModelToolResult([
new LanguageModelTextPart(result),
])
}
catch (error: any) {
return new LanguageModelToolResult([
new LanguageModelTextPart(`Error: ${error.message || error.toString()}`),
])
}
},
}))
}

function resolveProjectFromEntry(entry: string) {
if (entry === '' || entry === '$ACTIVE_SLIDE_ENTRY') {
if (!activeEntry.value) {
throw new Error('No active slide entry found. Please set an active slide entry before using this tool.')
}
entry = activeEntry.value
}

let project = projects.get(entry)
if (!project) {
entry = slash(entry)
const possibleProjects = [...projects.values()].filter(p => p.entry.includes(entry))
if (possibleProjects.length === 0) {
throw new Error(`No project found for entry: ${entry}. All entries: ${formatList(projects.keys())}`)
}
else if (possibleProjects.length > 1) {
throw new Error(`Multiple projects found for entry: ${entry}. Please specify the full path. All entries: ${formatList(projects.keys())}`)
}
else {
project = possibleProjects[0]
}
}

return project
}

function formatList(items: Iterable<string>): string {
const itemsArray = [...items]
if (itemsArray.length === 0) {
return 'No items found.'
}
return itemsArray.map(item => `- ${item}\n`).join('')
}

function formatObject(obj: Record<string, string | number>): string {
return Object.entries(obj)
.map(([key, value]) => `- ${key}: ${value}\n`)
.join('')
}