From 14f385f18e66ea429573996be50c2a12eb1b8378 Mon Sep 17 00:00:00 2001 From: Yusuke Nemoto Date: Sat, 6 Jul 2024 16:59:52 +0900 Subject: [PATCH] (Experimental) Add new command to decorate contents (#14) * feat: move context menu to submenu of Slidaiv * feat: add new command to decorate contents * refactor: move generate task to tasks.ts * fix a bit * chore: change order of context menu --- package.json | 38 ++++++++++++---- src/client/llmClient.ts | 3 +- src/client/openai.ts | 25 +++++++++-- src/client/prompts.ts | 17 ++++++- src/extension.ts | 99 ++++++++++++++++------------------------- src/logger.ts | 23 ++++++++++ src/model/slidev.ts | 4 +- src/tasks.ts | 74 ++++++++++++++++++++++++++++++ 8 files changed, 205 insertions(+), 78 deletions(-) create mode 100644 src/logger.ts create mode 100644 src/tasks.ts diff --git a/package.json b/package.json index 9c5d111..10b43ad 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,37 @@ { "command": "slidaiv.generateContents", "title": "Generate Slidev contents" + }, + { + "command": "slidaiv.decorateContents", + "title": "Decorate contents (Experimental)" + } + ], + "submenus": [ + { + "id": "slidaiv.submenu", + "label": "Slidaiv" } ], + "menus": { + "editor/context": [ + { + "submenu": "slidaiv.submenu", + "when": "editorLangId == markdown", + "group": "1_modification@99" + } + ], + "slidaiv.submenu": [ + { + "command": "slidaiv.generateContents", + "group": "slidaiv@1" + }, + { + "command": "slidaiv.decorateContents", + "group": "slidaiv@2" + } + ] + }, "configuration": { "title": "Slidaiv", "properties": { @@ -43,15 +72,6 @@ "description": "Model name" } } - }, - "menus": { - "editor/context": [ - { - "command": "slidaiv.generateContents", - "when": "editorLangId == markdown", - "group": "1_modification@99" - } - ] } }, "scripts": { diff --git a/src/client/llmClient.ts b/src/client/llmClient.ts index de53f49..5d6dd92 100644 --- a/src/client/llmClient.ts +++ b/src/client/llmClient.ts @@ -1,3 +1,4 @@ interface LLMClient { - generatePageContents(prompt: string, model: string, locale: string | null): Promise; + generatePageContents(prompt: string, locale: string | null): Promise; + decorateContents(prompt: string): Promise; } \ No newline at end of file diff --git a/src/client/openai.ts b/src/client/openai.ts index 968238f..f2a011e 100644 --- a/src/client/openai.ts +++ b/src/client/openai.ts @@ -1,22 +1,24 @@ import OpenAI from 'openai'; -import { getGenerateContentsPrompt } from './prompts'; +import { getDecorateContentsPrompt, getGenerateContentsPrompt } from './prompts'; import { getLocaleName } from '../utils'; export class Client implements LLMClient { private client: OpenAI; + private llmModel: string; private defaultLocale: string; - constructor(apiKey:string, baseURL:string|null, locale: string) { + constructor(apiKey:string, baseURL:string|null, llmModel: string, locale: string) { baseURL = baseURL || 'https://api.openai.com/v1'; this.client = new OpenAI({apiKey, baseURL}); + this.llmModel = llmModel; this.defaultLocale = locale; } - async generatePageContents(prompt: string, model:string, locale: string | null): Promise { + async generatePageContents(prompt: string, locale: string | null): Promise { const loc = getLocaleName(locale || this.defaultLocale); const sysPrompt = getGenerateContentsPrompt(loc); const resp = await this.client.chat.completions.create({ - model: model, + model: this.llmModel, messages: [{ "content": prompt, "role": "user", @@ -27,5 +29,20 @@ export class Client implements LLMClient { }); return resp.choices[0].message.content; } + + async decorateContents(prompt: string): Promise { + const sysPrompt = getDecorateContentsPrompt(); + const resp = await this.client.chat.completions.create({ + model: this.llmModel, + messages: [{ + "content": prompt, + "role": "user", + }, { + "content": sysPrompt, + "role": "system" + }], + }); + return resp.choices[0].message.content; + } } diff --git a/src/client/prompts.ts b/src/client/prompts.ts index eb9c707..019cc05 100644 --- a/src/client/prompts.ts +++ b/src/client/prompts.ts @@ -16,4 +16,19 @@ export function getGenerateContentsPrompt(locale: string) { Now, please provide instructions for the slide content. `; -} \ No newline at end of file +} + +export function getDecorateContentsPrompt() { + return ` + You are a Slidev Markdown decorator using Tailwind CSS and UnoCSS. Your task is to process the input Markdown and return ONLY the decorated input text. Follow these rules: + + 1. The input will be a portion of Slidev Markdown text. + 2. Must preserve all original text, Markdown structure, and line breaks exactly. + 3. Should add Tailwind CSS or UnoCSS classes to enhance visual appeal. + 4. Must not add any new text, explanations, or comments. + 5. Must not introduce any new line breaks, including those that might be caused by HTML tags like

or

. + 6. Must output only the decorated Markdown, nothing else such as explanations of the decoration. + + Your entire response must be valid Slidev Markdown, identical to the input except for added CSS classes. + `; +} diff --git a/src/extension.ts b/src/extension.ts index 4809b4b..0624178 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,70 +2,47 @@ import * as vscode from 'vscode'; import { ExtensionID } from './constants'; import { Client } from './client/openai'; -import { SlidevPage } from './model/slidev'; +import { Logger } from './logger'; +import { getTaskDecorateContent, getTaskGenerateContents } from './tasks'; export function activate(context: vscode.ExtensionContext) { - const apiKey:string = vscode.workspace.getConfiguration(ExtensionID).get('apiKey') || ''; - const baseUrl:string|null = vscode.workspace.getConfiguration(ExtensionID).get('baseUrl') || null; - const client = new Client(apiKey, baseUrl, vscode.env.language); - - let disposable = vscode.commands.registerCommand('slidaiv.generateContents', async () => { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showErrorMessage('No active editor'); - return; + // TODO: make this configurable + const debugMode = true; + const logger = new Logger(vscode.window.createOutputChannel('Slidaiv'), debugMode); + logger.info('Slidaiv is now active'); + + const apiKey: string = vscode.workspace.getConfiguration(ExtensionID).get('apiKey') || ''; + const baseUrl: string | null = vscode.workspace.getConfiguration(ExtensionID).get('baseUrl') || null; + const llmModel: string = vscode.workspace.getConfiguration(ExtensionID).get('model') || ''; + const client = new Client(apiKey, baseUrl, llmModel, vscode.env.language); + logger.info(`#{baseUrl: ${baseUrl}, model: ${llmModel}, locale: ${vscode.env.language}`); + + + context.subscriptions.push(vscode.commands.registerCommand('slidaiv.generateContents', async () => { + try { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Generating Slidev contents', + cancellable: false + }, getTaskGenerateContents(client, logger)); + } catch (e: any) { + vscode.window.showErrorMessage(`failed to generate content: ${e.message}`); + logger.error(`failed to generate content: ${e.message}`); } - - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: 'Generating Slidev contents', - cancellable: false - }, async (progress) => { - progress.report({ increment: 0, message: 'Parsing Slidev contents'}); - const position = editor.selection.active; - - let slidevPage: SlidevPage; - let page: string; - // parse text and generate contents from LLM - try { - slidevPage = await SlidevPage.init( - editor.document.getText(), - editor.document.fileName, - position.line - ); - progress.report({ increment: 10, message: 'Generating Slidev contents'}); - const llmModel: string = vscode.workspace.getConfiguration(ExtensionID).get('model') || ''; - page = await slidevPage.rewriteByLLM(client, llmModel); - } catch (e: any) { - vscode.window.showErrorMessage(`failed to generate Slidev content: ${e.message}`); - progress.report({ increment: 100 }); - return; - } - - progress.report({ increment: 40, message: 'Replace the slide contents'}); - - // apply the generated contents to the editor - try { - const range = new vscode.Range(slidevPage.start, 0, slidevPage.end, 0); - const edit = new vscode.WorkspaceEdit(); - edit.replace(editor.document.uri, range , page); - const isEdited = await vscode.workspace.applyEdit(edit); - if (!isEdited) { - vscode.window.showErrorMessage('Failed to replace the slide contents'); - progress.report({ increment: 10 }); - return; - } - progress.report({ increment: 50 }); - } catch (e: any) { - vscode.window.showErrorMessage(`failed to apply content: ${e.message}`); - progress.report({ increment: 100 }); - return; - } - }); - }); - - context.subscriptions.push(disposable); + })); + context.subscriptions.push(vscode.commands.registerCommand('slidaiv.decorateContents', async () => { + try { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Decorating Slidev contents', + cancellable: false + }, getTaskDecorateContent(client, logger)); + } catch (e: any) { + vscode.window.showErrorMessage(`failed to decorate content: ${e.message}`); + logger.error(`failed to decorate content: ${e.message}`); + } + })); } // This method is called when your extension is deactivated -export function deactivate() {} +export function deactivate() { } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..7282154 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,23 @@ +import type * as vscode from 'vscode'; + +export class Logger { + private readonly out: vscode.OutputChannel; + private isDebug: boolean; + constructor(out: vscode.OutputChannel, debug: boolean) { + this.out = out; + this.isDebug = debug; + } + + error(message: string) { + this.out.appendLine(`[ERROR] ${message}`); + } + + info(message: string) { + this.out.appendLine(`[INFO] ${message}`); + } + + debug(message: string) { + if (!this.isDebug) return; + this.out.appendLine(`[DEBUG] ${message}`); + } +} \ No newline at end of file diff --git a/src/model/slidev.ts b/src/model/slidev.ts index 4a43300..25834c4 100644 --- a/src/model/slidev.ts +++ b/src/model/slidev.ts @@ -31,9 +31,9 @@ export class SlidevPage { return new SlidevPage(slide); } - async rewriteByLLM(client: LLMClient, model: string) { + async rewriteByLLM(client: LLMClient) { const prompt = this.prompts.map((prompt: string) => `- ${prompt}`).join('\n'); - const content = await client.generatePageContents(prompt, model, this.locale); + const content = await client.generatePageContents(prompt, this.locale); return `${obj2frontmatter(this.frontmatter)}\n\n${content}\n\n`; } diff --git a/src/tasks.ts b/src/tasks.ts new file mode 100644 index 0000000..847492f --- /dev/null +++ b/src/tasks.ts @@ -0,0 +1,74 @@ +import * as vscode from 'vscode'; + +import { Client } from "./client/openai"; +import { Logger } from "./logger"; +import { SlidevPage } from './model/slidev'; + +export const getTaskGenerateContents = (client: Client, logger: Logger) => { + return async (progress: vscode.Progress) => { + logger.info('Generating contents'); + progress.report({ increment: 0, message: 'Parsing Slidev contents' }); + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('No active editor'); + } + const position = editor.selection.active; + const slidevPage = await SlidevPage.init( + editor.document.getText(), + editor.document.fileName, + position.line + ); + + progress.report({ increment: 10, message: 'Generating Slidev contents' }); + const page = await slidevPage.rewriteByLLM(client); + + progress.report({ increment: 80, message: 'Write the generated slide contents' }); + const range = new vscode.Range(slidevPage.start, 0, slidevPage.end, 0); + const edit = new vscode.WorkspaceEdit(); + edit.replace(editor.document.uri, range, page); + const isEdited = await vscode.workspace.applyEdit(edit); + if (!isEdited) { + throw new Error('Failed to write the generated slide contents'); + } + + progress.report({ increment: 10, message: 'Done' }); + } +} + +export const getTaskDecorateContent = (client: Client, logger: Logger) => { + return async (progress: vscode.Progress) => { + logger.info('Decorating contents'); + progress.report({ increment: 0, message: 'Get text to decorate' }); + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage('No active editor'); + return; + } + const selection = editor.selection; + if (!selection || selection.isEmpty) { + vscode.window.showErrorMessage('No selection'); + return; + } + const highlighted = editor.document.getText(selection); + logger.debug(`selection: \n${highlighted}`); + + progress.report({ increment: 10, message: 'Calling LLM...' }); + logger.info('Call LLM to decorate the contents'); + const decorated = await client.decorateContents(highlighted); + logger.debug(`decorated: \n${decorated}`); + if (!decorated) { + throw new Error('Failed to decorate the contents'); + } + + progress.report({ increment: 80, message: 'Write the decorated text' }); + logger.info('Write the slide contents'); + const edit = new vscode.WorkspaceEdit(); + edit.replace(editor.document.uri, selection, decorated); + const isEdited = await vscode.workspace.applyEdit(edit); + if (!isEdited) { + throw new Error('Failed to replace the slide contents'); + } + + progress.report({ increment: 10, message: 'Done' }); + } +} \ No newline at end of file