diff --git a/src/components/Icons.ts b/src/components/Icons.ts index 2af111c..3bdc54e 100644 --- a/src/components/Icons.ts +++ b/src/components/Icons.ts @@ -4,6 +4,12 @@ * 定义常用的图标 */ export const Icons = { + AiAsk: ``, + AiContinue: ``, + AiRewrite: ``, + AiReview: ``, + AiTranslate: ``, + AiImage: ``, ArrowDown: ``, Loading: ``, // Loading: ``, diff --git a/src/core/UAIEditor.ts b/src/core/UAIEditor.ts index bb57e8c..615237e 100644 --- a/src/core/UAIEditor.ts +++ b/src/core/UAIEditor.ts @@ -26,9 +26,11 @@ import i18next from "i18next"; import { zh } from "../i18n/zh.ts"; import { Resource } from "i18next"; import { allExtensions } from "./UAIExtensions.ts"; +import { AICommand } from "../ai/config/AIConfig.ts"; import { AIChatConfig, Text2ImageConfig } from "../ai/config/AIConfig.ts"; import { markdownToHtml } from "../utils/MarkdownUtil.ts"; import { Uploader } from "../utils/FileUploader.ts"; +import { Icons } from "../components/Icons.ts"; self.MonacoEnvironment = { getWorker(_workerId, _label) { @@ -111,15 +113,63 @@ export type UAIEditorOptions = { ai?: { chat?: { models?: Record, + commands?: AICommand[], }, image?: { models?: { text2image?: Record, }, + commands?: AICommand[] } } } +/** + * 定义默认的对话快捷命令 + */ +const defaultChatCommands = [ + { + icon: Icons.AiAsk, + name: "AI 问答", + model: "default", + }, + { + icon: Icons.AiContinue, + name: "AI 续写", + prompt: "请帮我继续扩展一下这段话的内容。", + model: "default", + }, + { + icon: Icons.AiRewrite, + name: "AI 重写", + prompt: "请帮我重写写一下这段话的内容。", + model: "default", + }, + { + icon: Icons.AiReview, + name: "AI 校阅", + prompt: "请帮我改正这段话中的错别字和语法错误。", + model: "default", + }, + { + icon: Icons.AiTranslate, + name: "AI 翻译", + prompt: "请帮我做这段话的中英文互译。注意,你只需要返回翻译的结果,不需要对此进行任何解释,不需要除了翻译结果以外的其他任何内容。", + model: "default", + }, +] + +/** + * 定义默认的文生图命令 + */ +const defaultImageCommands = [ + { + icon: Icons.AiImage, + name: "AI 生图", + model: "default", + }, +] + /** * 定义内部编辑器类 */ @@ -258,11 +308,13 @@ export class UAIEditor { ai: { chat: { models: customOptions.ai?.chat?.models, + commands: customOptions.ai?.chat?.commands ?? defaultChatCommands }, image: { models: { text2image: customOptions.ai?.image?.models?.text2image, }, + commands: customOptions.ai?.image?.commands ?? defaultImageCommands } } }; diff --git a/src/core/UAIExtensions.ts b/src/core/UAIExtensions.ts index 1e16794..bf9bfc5 100644 --- a/src/core/UAIExtensions.ts +++ b/src/core/UAIExtensions.ts @@ -34,6 +34,7 @@ import Indent from "../extensions/Indent.ts"; import LineHeight from "../extensions/LineHeight.ts" import NodeAlign from "../extensions/NodeAlign.ts"; import OrderedList from "../extensions/OrderedList.ts"; +import QuickCommand from "../extensions/QuickCommand.ts"; import SelectFile from "../extensions/SelectFile.ts"; import Selection from "../extensions/Selection.ts"; import Shortcuts from "../extensions/Shortcuts.ts"; @@ -232,6 +233,7 @@ export const allExtensions = (uaiEditor: UAIEditor, _options: UAIEditorOptions): }), NodeAlign, OrderedList, + QuickCommand, SelectFile.configure({ allowedMimeTypes: [] }), diff --git a/src/extensions/QuickCommand.ts b/src/extensions/QuickCommand.ts new file mode 100644 index 0000000..3657b11 --- /dev/null +++ b/src/extensions/QuickCommand.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { Extension } from '@tiptap/core'; + +import Suggestion, { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'; +import tippy, { Instance } from 'tippy.js'; +import { InnerEditor } from '../core/UAIEditor.ts'; +import { AICommand } from '../ai/config/AIConfig.ts'; +import OpenAI from "openai"; +import { markdownToHtml } from '../utils/MarkdownUtil.ts'; +import { uuid } from '../utils/UUID.ts'; + +export type QuickCommandOptions = { + HTMLAttributes?: Record; + suggestion: Omit; +} + +/** + * 定义快捷命令 + */ +export default Extension.create({ + name: 'quickCommand', + addOptions() { + return { + suggestion: { + char: '/', + command: ({ editor, range, props }) => { + editor.chain().focus().deleteRange(range).run(); + const from = editor.state.selection.to; + const type = props.type; + const command = props.command as AICommand; + const options = (editor as InnerEditor).uaiEditor.options; + const actionKey = "uai_waiting_action"; + + const selectedText = editor.state.selection.$head.parent.textContent; + if (type === "chat") { + // 模型对话命令 + var models = Object.keys(options.ai?.chat?.models ?? {}); + if (models.length > 0) { + var model = command.model ?? "auto"; + if (model === "auto") { + model = models[0]; + } + const modelConfig = options.ai!.chat!.models![model]; + const client = new OpenAI({ + baseURL: modelConfig.baseUrl, + apiKey: modelConfig.apiKey, + dangerouslyAllowBrowser: true, + }); + + client.chat.completions.create({ + model: modelConfig.model ?? "o1", + stream: true, + max_tokens: modelConfig.max_tokens, + temperature: modelConfig.temperature, + top_p: modelConfig.top_p, + frequency_penalty: modelConfig.frequency_penalty, + messages: [ + { "role": "system", "content": command.prompt ?? "你是一个很有帮助的人工智能助手。" }, + { "role": "user", "content": selectedText } + ], + }).then(async response => { + for await (var chunk of response) { + var content = chunk.choices[0]?.delta?.content || ''; + editor.view.dispatch(editor.state.tr.insertText(content)); + } + const end = editor.state.selection.to; + const insertedText = editor.state.doc.textBetween(from, end); + editor.view.dispatch(editor.state.tr.replaceWith(from, end, (editor as InnerEditor).parseHtml(markdownToHtml(insertedText))).scrollIntoView()); + }); + } + } else if (type === "text2image") { + // 文生图命令 + var models = Object.keys(options.ai?.image?.models?.text2image ?? {}); + const id = uuid() + if (models.length > 0) { + var model = command.model ?? "auto"; + if (model === "auto") { + model = models[0]; + } + const modelConfig = options.ai!.image!.models!.text2image![model]; + const client = new OpenAI({ + baseURL: modelConfig.baseUrl, + apiKey: modelConfig.apiKey, + dangerouslyAllowBrowser: true, + }); + editor.view.dispatch(editor.state.tr.setMeta(actionKey, { + type: "add", + id, + pos: editor.state.tr.selection.from, + })); + client.images.generate({ + "model": modelConfig.model ?? "dall-e-3", + "prompt": `${command.prompt ?? ""}${selectedText}`, + "size": "1024x1024", + }).then(response => { + editor.view.dispatch(editor.state.tr.setMeta(actionKey, { type: "remove", id })); + const previewType = "image"; + const type = "image"; + + editor.commands.insertContentAt(editor.state.tr.selection.from, { + type: type, + attrs: { + ['src']: response.data[0].url, + type, + previewType, + }, + }); + }); + } + } + }, + render: () => { + let container: HTMLElement; + let popup: Instance; + + return { + onStart: (props: SuggestionProps) => { + container = document.createElement("div"); + container.classList.add("uai-popup-action-list"); + (props.editor as InnerEditor).uaiEditor.options.ai?.chat?.commands?.forEach(command => { + const item = document.createElement("div"); + item.classList.add("uai-popup-action-item"); + item.innerHTML = `${command.icon}  ${command.name}`; + item.addEventListener('click', () => { + props.command({ + type: "chat", + command: command + }) + }); + container.appendChild(item); + }) + + container.appendChild(document.createElement("hr")); + + (props.editor as InnerEditor).uaiEditor.options.ai?.image?.commands?.forEach(command => { + const item = document.createElement("div"); + item.classList.add("uai-popup-action-item"); + item.innerHTML = `${command.icon}  ${command.name}`; + item.addEventListener('click', () => { + props.command({ + type: "text2image", + command: command + }) + }); + container.appendChild(item); + }) + + // @ts-ignore + popup = tippy('body', { + appendTo: props.editor.options.element, + getReferenceClientRect: props.clientRect, + content: container, + showOnCreate: true, + interactive: true, + allowHTML: true, + trigger: 'manual', + placement: 'right', + arrow: false, + })[0] + }, + onUpdate(props) { + if (!props.clientRect) { + return; + } + popup.setProps({ + getReferenceClientRect: props.clientRect as any, + }) + }, + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup.hide(); + return true; + } + return false; + }, + onExit() { + popup.hide(); + container.remove(); + }, + } + }, + } + } + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) \ No newline at end of file