From 6e5e2ccd4e6c9479b47b12054ed28025da77d491 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 21 Jun 2025 13:49:08 +0800 Subject: [PATCH 1/2] feat(vscode): lm tools --- packages/vscode/package.json | 45 +++++++++++++++ packages/vscode/src/index.ts | 4 ++ packages/vscode/src/lmTools.ts | 102 +++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 packages/vscode/src/lmTools.ts diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 2907ef6fd3..33dd52226b 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -434,6 +434,51 @@ "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." + } + } + } + } ] }, "scripts": { diff --git a/packages/vscode/src/index.ts b/packages/vscode/src/index.ts index d212a94f06..7cf856b825 100644 --- a/packages/vscode/src/index.ts +++ b/packages/vscode/src/index.ts @@ -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' @@ -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}`) diff --git a/packages/vscode/src/lmTools.ts b/packages/vscode/src/lmTools.ts new file mode 100644 index 0000000000..b124242a22 --- /dev/null +++ b/packages/vscode/src/lmTools.ts @@ -0,0 +1,102 @@ +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)}` + }) +}) + +function registerSimpleTool(name: string, invoke: (input: T) => string) { + useDisposable(lm.registerTool(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 { + const itemsArray = [...items] + if (itemsArray.length === 0) { + return 'No items found.' + } + return itemsArray.map(item => `- ${item}\n`).join('') +} + +function formatObject(obj: Record): string { + return Object.entries(obj) + .map(([key, value]) => `- ${key}: ${value}\n`) + .join('') +} From 87c5578dc1d5835449d372393872b3cdcac32721 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 1 Jul 2025 18:06:28 +0800 Subject: [PATCH 2/2] feat: more tools --- packages/vscode/package.json | 99 ++++++++++++++++++++++++++++++++++ packages/vscode/src/lmTools.ts | 50 +++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 33dd52226b..3e27e510ad 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -478,6 +478,105 @@ } } } + }, + { + "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"] + } } ] }, diff --git a/packages/vscode/src/lmTools.ts b/packages/vscode/src/lmTools.ts index b124242a22..ca2cf74327 100644 --- a/packages/vscode/src/lmTools.ts +++ b/packages/vscode/src/lmTools.ts @@ -41,6 +41,56 @@ export const useLmTools = createSingletonComposable(() => { 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(name: string, invoke: (input: T) => string) {