Skip to content

Commit 6a497c3

Browse files
authored
feat(vscode): add language model tools (#2209)
1 parent 5625943 commit 6a497c3

File tree

3 files changed

+300
-0
lines changed

3 files changed

+300
-0
lines changed

packages/vscode/package.json

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,150 @@
434434
"view": "slidev-slides-tree",
435435
"contents": "No active slides entry.\n[Choose one](command:slidev.choose-entry)"
436436
}
437+
],
438+
"languageModelTools": [
439+
{
440+
"name": "slidev_getActiveSlide",
441+
"tags": [
442+
"slidev"
443+
],
444+
"toolReferenceName": "getActiveSlide",
445+
"displayName": "Get Active Slide",
446+
"modelDescription": "Get the information of the active slide the user is currently focused on in a Slidev presentation.",
447+
"userDescription": "Get the information of the active slide in a Slidev presentation.",
448+
"canBeReferencedInPrompt": true,
449+
"icon": "$(debug-stackframe-active)",
450+
"inputSchema": {
451+
"type": "object",
452+
"properties": {
453+
}
454+
}
455+
},
456+
{
457+
"name": "slidev_getSlideContent",
458+
"tags": [
459+
"slidev"
460+
],
461+
"toolReferenceName": "getSlideContent",
462+
"displayName": "Get Slide Content",
463+
"modelDescription": "Get the content of a specific slide in a Slidev presentation by providing the slide number.",
464+
"userDescription": "Get the content of a specific slide in a Slidev presentation by providing the slide number.",
465+
"canBeReferencedInPrompt": true,
466+
"icon": "$(file-code)",
467+
"inputSchema": {
468+
"type": "object",
469+
"properties": {
470+
"entrySlidePath": {
471+
"type": "string",
472+
"description": "The path to the Slidev entry file (e.g., `./slides.md`). Empty string means the active slide entry.",
473+
"default": "$ACTIVE_SLIDE_ENTRY"
474+
},
475+
"slideNo": {
476+
"type": "number",
477+
"description": "The slide number to retrieve content from. Starts from 1. Hidden slides are not counted."
478+
}
479+
}
480+
}
481+
},
482+
{
483+
"name": "slidev_getAllSlideTitles",
484+
"tags": ["slidev"],
485+
"toolReferenceName": "getAllSlideTitles",
486+
"displayName": "Get All Slide Titles",
487+
"modelDescription": "Get the list of all slide titles in the specified Slidev project.",
488+
"userDescription": "Get the list of all slide titles in the specified Slidev project.",
489+
"canBeReferencedInPrompt": true,
490+
"icon": "$(list-unordered)",
491+
"inputSchema": {
492+
"type": "object",
493+
"properties": {
494+
"entrySlidePath": {
495+
"type": "string",
496+
"description": "The path to the Slidev entry file (e.g., ./slides.md). Empty string means the active slide entry.",
497+
"default": "$ACTIVE_SLIDE_ENTRY"
498+
}
499+
}
500+
}
501+
},
502+
{
503+
"name": "slidev_findSlideNoByTitle",
504+
"tags": ["slidev"],
505+
"toolReferenceName": "findSlideNoByTitle",
506+
"displayName": "Find Slide Number by Title",
507+
"modelDescription": "Find the slide number in the specified Slidev project by its title.",
508+
"userDescription": "Find the slide number in the specified Slidev project by its title.",
509+
"canBeReferencedInPrompt": true,
510+
"icon": "$(search)",
511+
"inputSchema": {
512+
"type": "object",
513+
"properties": {
514+
"entrySlidePath": {
515+
"type": "string",
516+
"description": "The path to the Slidev entry file (e.g., ./slides.md). Empty string means the active slide entry.",
517+
"default": "$ACTIVE_SLIDE_ENTRY"
518+
},
519+
"title": {
520+
"type": "string",
521+
"description": "The title of the slide to search for."
522+
}
523+
}
524+
}
525+
},
526+
{
527+
"name": "slidev_listEntries",
528+
"tags": ["slidev"],
529+
"toolReferenceName": "listEntries",
530+
"displayName": "List All Loaded Slidev Entries",
531+
"modelDescription": "Get all loaded Slidev project entry file paths.",
532+
"userDescription": "Get all loaded Slidev project entry file paths.",
533+
"canBeReferencedInPrompt": true,
534+
"icon": "$(file-directory)",
535+
"inputSchema": {
536+
"type": "object",
537+
"properties": {}
538+
}
539+
},
540+
{
541+
"name": "slidev_getPreviewPort",
542+
"tags": ["slidev"],
543+
"toolReferenceName": "getPreviewPort",
544+
"displayName": "Get Project Preview Port",
545+
"modelDescription": "Get the preview port number of the specified Slidev project.",
546+
"userDescription": "Get the preview port number of the specified Slidev project.",
547+
"canBeReferencedInPrompt": true,
548+
"icon": "$(plug)",
549+
"inputSchema": {
550+
"type": "object",
551+
"properties": {
552+
"entrySlidePath": {
553+
"type": "string",
554+
"description": "The path to the Slidev entry file (e.g., ./slides.md). Empty string means the active slide entry.",
555+
"default": "$ACTIVE_SLIDE_ENTRY"
556+
}
557+
}
558+
}
559+
},
560+
{
561+
"name": "slidev_chooseEntry",
562+
"tags": ["slidev"],
563+
"toolReferenceName": "chooseEntry",
564+
"displayName": "Choose Active Slidev Entry",
565+
"modelDescription": "Switch the active Slidev project entry to the specified entry file path.",
566+
"userDescription": "Switch the active Slidev project entry to the specified entry file path.",
567+
"canBeReferencedInPrompt": true,
568+
"icon": "$(arrow-swap)",
569+
"inputSchema": {
570+
"type": "object",
571+
"properties": {
572+
"entrySlidePath": {
573+
"type": "string",
574+
"description": "The path to the Slidev entry file to activate (e.g., ./slides.md).",
575+
"default": ""
576+
}
577+
},
578+
"required": ["entrySlidePath"]
579+
}
580+
}
437581
]
438582
},
439583
"scripts": {

packages/vscode/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { defineExtension } from 'reactive-vscode'
22
import { useCommands } from './commands'
33
import { useLanguageClient } from './languageClient'
4+
import { useLmTools } from './lmTools'
45
import { activeEntry, useProjects } from './projects'
56
import { useAnnotations } from './views/annotations'
67
import { useFoldings } from './views/foldings'
@@ -26,6 +27,9 @@ const { activate, deactivate } = defineExtension(() => {
2627
// language server
2728
const labsInfo = useLanguageClient()
2829

30+
// language model tools
31+
useLmTools()
32+
2933
logger.info('Slidev activated.')
3034
logger.info(`Entry: ${activeEntry.value}`)
3135

packages/vscode/src/lmTools.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { slash } from '@antfu/utils'
2+
import { stringifySlide } from '@slidev/parser/core'
3+
import { createSingletonComposable, useDisposable } from 'reactive-vscode'
4+
import { LanguageModelTextPart, LanguageModelToolResult, lm } from 'vscode'
5+
import { useEditingSlideSource } from './composables/useEditingSlideSource'
6+
import { useFocusedSlideNo } from './composables/useFocusedSlideNo'
7+
import { activeEntry, activeProject, projects } from './projects'
8+
9+
export const useLmTools = createSingletonComposable(() => {
10+
const focusedSlideNo = useFocusedSlideNo()
11+
const editingSlide = useEditingSlideSource()
12+
13+
registerSimpleTool('slidev_getActiveSlide', () => {
14+
const project = activeProject.value
15+
16+
if (project == null) {
17+
throw new Error(`No active slide project found.`)
18+
}
19+
20+
return formatObject({
21+
'Entry file': project.entry,
22+
'Root directory': project.userRoot,
23+
'Preview server port': project.port || 'Not running',
24+
'Number of slides': project.data.slides.length,
25+
'Focused slide no. in presentation (from 1)': focusedSlideNo.value,
26+
'Editing file': editingSlide.markdown.value?.filepath || 'Not editing',
27+
'Editing slide index in file (from 0)': editingSlide.index.value,
28+
})
29+
})
30+
31+
registerSimpleTool('slidev_getSlideContent', (input: {
32+
entrySlidePath: string
33+
slideNo: number
34+
}) => {
35+
const project = resolveProjectFromEntry(input.entrySlidePath)
36+
const slide = project.data.slides[input.slideNo - 1]
37+
38+
if (slide == null) {
39+
throw new Error(`No content found for slide number ${input.slideNo} in entry: ${project.entry}. Available slides numbers: 1-${project.data.slides.length}`)
40+
}
41+
42+
return `Content of slide number ${input.slideNo} in entry "${project.entry}" in file "${slide.source.filepath}":\n\n${stringifySlide(slide.source, 1)}`
43+
})
44+
45+
// Get all slide titles
46+
registerSimpleTool('slidev_getAllSlideTitles', (input: { entrySlidePath: string }) => {
47+
const project = resolveProjectFromEntry(input.entrySlidePath)
48+
const titles = project.data.slides.map((slide, idx) => `#${idx + 1}: ${slide.title || '(Untitled)'}`)
49+
return formatList(titles)
50+
})
51+
52+
// Find slide number by title
53+
registerSimpleTool('slidev_findSlideNoByTitle', (input: { entrySlidePath: string, title: string }) => {
54+
const project = resolveProjectFromEntry(input.entrySlidePath)
55+
const idx = project.data.slides.findIndex(slide => slide.title === input.title)
56+
if (idx === -1) {
57+
throw new Error(`No slide found with title: "${input.title}".`)
58+
}
59+
return formatObject({
60+
'Title': input.title,
61+
'Slide number': idx + 1,
62+
})
63+
})
64+
65+
// List all loaded Slidev entries
66+
registerSimpleTool('slidev_listEntries', () => {
67+
const entries = [...projects.keys()]
68+
if (entries.length === 0) {
69+
return 'No loaded Slidev project entries.'
70+
}
71+
return formatList(entries)
72+
})
73+
74+
// Get project preview port
75+
registerSimpleTool('slidev_getPreviewPort', (input: { entrySlidePath: string }) => {
76+
const project = resolveProjectFromEntry(input.entrySlidePath)
77+
return formatObject({
78+
'Project entry': project.entry,
79+
'Preview port': project.port || 'Not running',
80+
})
81+
})
82+
83+
// Choose active Slidev entry
84+
registerSimpleTool('slidev_chooseEntry', (input: { entrySlidePath: string }) => {
85+
if (!input.entrySlidePath) {
86+
throw new Error('entrySlidePath is required.')
87+
}
88+
const project = resolveProjectFromEntry(input.entrySlidePath)
89+
activeEntry.value = project.entry
90+
return formatObject({
91+
'Active entry switched to': project.entry,
92+
})
93+
})
94+
})
95+
96+
function registerSimpleTool<T>(name: string, invoke: (input: T) => string) {
97+
useDisposable(lm.registerTool<T>(name, {
98+
invoke({ input }) {
99+
try {
100+
const result = invoke(input)
101+
return new LanguageModelToolResult([
102+
new LanguageModelTextPart(result),
103+
])
104+
}
105+
catch (error: any) {
106+
return new LanguageModelToolResult([
107+
new LanguageModelTextPart(`Error: ${error.message || error.toString()}`),
108+
])
109+
}
110+
},
111+
}))
112+
}
113+
114+
function resolveProjectFromEntry(entry: string) {
115+
if (entry === '' || entry === '$ACTIVE_SLIDE_ENTRY') {
116+
if (!activeEntry.value) {
117+
throw new Error('No active slide entry found. Please set an active slide entry before using this tool.')
118+
}
119+
entry = activeEntry.value
120+
}
121+
122+
let project = projects.get(entry)
123+
if (!project) {
124+
entry = slash(entry)
125+
const possibleProjects = [...projects.values()].filter(p => p.entry.includes(entry))
126+
if (possibleProjects.length === 0) {
127+
throw new Error(`No project found for entry: ${entry}. All entries: ${formatList(projects.keys())}`)
128+
}
129+
else if (possibleProjects.length > 1) {
130+
throw new Error(`Multiple projects found for entry: ${entry}. Please specify the full path. All entries: ${formatList(projects.keys())}`)
131+
}
132+
else {
133+
project = possibleProjects[0]
134+
}
135+
}
136+
137+
return project
138+
}
139+
140+
function formatList(items: Iterable<string>): string {
141+
const itemsArray = [...items]
142+
if (itemsArray.length === 0) {
143+
return 'No items found.'
144+
}
145+
return itemsArray.map(item => `- ${item}\n`).join('')
146+
}
147+
148+
function formatObject(obj: Record<string, string | number>): string {
149+
return Object.entries(obj)
150+
.map(([key, value]) => `- ${key}: ${value}\n`)
151+
.join('')
152+
}

0 commit comments

Comments
 (0)