From 213c79671339dc5f5478bce64301bff104ff50e5 Mon Sep 17 00:00:00 2001 From: wux_labs Date: Wed, 8 Jan 2025 21:23:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=A4=A7=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai/config/AIConfig.ts | 37 ++ src/components/Footer.ts | 10 + src/components/Icons.ts | 9 + src/components/ai/AIAction.ts | 222 ++++++++++++ src/components/containers/ChatContainer.ts | 323 ++++++++++++++++++ src/components/index.ts | 11 + src/components/menus/toolbar/Classic.ts | 36 ++ src/components/menus/toolbar/Ribbon.ts | 36 ++ src/components/menus/toolbar/ai/ToggleChat.ts | 79 +++++ src/core/UAIEditor.ts | 31 +- src/utils/CreateAvatar.ts | 14 + 11 files changed, 807 insertions(+), 1 deletion(-) create mode 100644 src/ai/config/AIConfig.ts create mode 100644 src/components/ai/AIAction.ts create mode 100644 src/components/containers/ChatContainer.ts create mode 100644 src/components/menus/toolbar/ai/ToggleChat.ts create mode 100644 src/utils/CreateAvatar.ts diff --git a/src/ai/config/AIConfig.ts b/src/ai/config/AIConfig.ts new file mode 100644 index 0000000..e2b588f --- /dev/null +++ b/src/ai/config/AIConfig.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2024-present AI-Labs + +/** + * 模型对话的基本配置信息 + */ +export interface AIChatConfig { + modelType: "openai" | "custom" | "LMDeploy"; + + temperature?: number; + max_tokens?: number; + top_p?: number; + top_k?: number; + frequency_penalty?: number; + + baseUrl?: string; // openai && gitee + apiKey?: string; // openai && gitee && spark + model?: string; // openai + + appId?: string, // spark + apiSecret?: string, // spark + protocol?: string, // spark && wenxin + version?: string, // spark && wenxin + + access_token?: string, // wenxin +} + +/** + * 快捷命令基本配置信息 + */ +export interface AICommand { + icon?: string, + name: string, + prompt?: string, + text?: "selected" | "focusBefore", + model?: string, + action?: () => void, +} diff --git a/src/components/Footer.ts b/src/components/Footer.ts index 4ccc0d8..2912ed1 100644 --- a/src/components/Footer.ts +++ b/src/components/Footer.ts @@ -5,6 +5,7 @@ import { EditorEvents } from "@tiptap/core"; import { UAIEditorEventListener, UAIEditorOptions } from "../core/UAIEditor.ts"; import { ToggleToc } from "./menus/toolbar/page/ToggleToc"; +import { ToggleChat } from "./menus/toolbar/ai/ToggleChat.ts"; import { CharacterCount } from "./menus/statusbar/CharacterCount.ts"; import { Fullscreen } from "./menus/statusbar/Fullscreen.ts"; @@ -19,6 +20,8 @@ export class Footer extends HTMLElement implements UAIEditorEventListener { // 文档大纲 toggleToc!: ToggleToc; + // 模型对话 + toggleChat!: ToggleChat; // 字数统计 characterCount!: CharacterCount; @@ -35,6 +38,7 @@ export class Footer extends HTMLElement implements UAIEditorEventListener { // 创建状态栏菜单 this.toggleToc = new ToggleToc({ menuType: "button", enable: true }); + this.toggleChat = new ToggleChat({ menuType: "button", enable: true }); this.characterCount = new CharacterCount(); this.fullscreen = new Fullscreen({ menuType: "button", enable: true }); @@ -50,6 +54,7 @@ export class Footer extends HTMLElement implements UAIEditorEventListener { onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { // 初始化状态栏菜单 this.toggleToc.onCreate(event, options); + this.toggleChat.onCreate(event, options); this.characterCount.onCreate(event, options); this.fullscreen.onCreate(event, options); @@ -76,12 +81,15 @@ export class Footer extends HTMLElement implements UAIEditorEventListener { statusBar.appendChild(statusBarRight); this.toggleToc.classList.add("uai-status-bar-button"); + this.toggleChat.classList.add("uai-status-bar-button"); this.fullscreen.classList.add("uai-status-bar-button"); // 状态栏中添加功能菜单按钮 statusBarLeft.appendChild(this.toggleToc); statusBarLeft.appendChild(this.createSplit()); + statusBarLeft.appendChild(this.toggleChat); + statusBarLeft.appendChild(this.createSplit()); statusBarLeft.appendChild(this.characterCount); statusBarRight.appendChild(this.fullscreen); @@ -107,6 +115,7 @@ export class Footer extends HTMLElement implements UAIEditorEventListener { */ onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { this.toggleToc.onTransaction(event, options); + this.toggleChat.onTransaction(event, options); this.characterCount.onTransaction(event, options); this.fullscreen.onTransaction(event, options); @@ -116,6 +125,7 @@ export class Footer extends HTMLElement implements UAIEditorEventListener { onEditableChange(editable: boolean) { this.toggleToc.onEditableChange(editable); + this.toggleChat.onEditableChange(editable); this.characterCount.onEditableChange(editable); this.fullscreen.onEditableChange(editable); diff --git a/src/components/Icons.ts b/src/components/Icons.ts index 7f78569..2af111c 100644 --- a/src/components/Icons.ts +++ b/src/components/Icons.ts @@ -7,4 +7,13 @@ export const Icons = { ArrowDown: ``, Loading: ``, // Loading: ``, + BotAvatar: '🤖', + UserAvatar: '😀', + SendIcon: ` + + +`, + EditAction: ``, + ReplaceAction: ``, + InsertAction: `` } diff --git a/src/components/ai/AIAction.ts b/src/components/ai/AIAction.ts new file mode 100644 index 0000000..4a21506 --- /dev/null +++ b/src/components/ai/AIAction.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { Editor, EditorEvents } from "@tiptap/core"; +import { Slice } from "@tiptap/pm/model"; +import iconTask from "../../assets/icons/task-list.svg"; + +import { Icons } from "../Icons"; +import { AICommand } from "../../ai/config/AIConfig"; +import { InnerEditor, UAIEditorEventListener, UAIEditorOptions } from "../../core/UAIEditor"; +import { markdownToHtml } from "../../utils/MarkdownUtil"; +import { t } from "i18next"; +import { MenuButton, MenuButtonOptions } from "../menus/MenuButton"; +import tippy, { Instance, Props } from "tippy.js"; + +/** + * 抽象AI Action类 + */ +export abstract class AbstractAIAction extends HTMLElement implements UAIEditorEventListener { + editor!: Editor; + aiActions: AICommand[] = []; + // 按钮的选项设置信息 + menuButtonOptions: MenuButtonOptions = { + menuType: "popup", + enable: true, + icon: iconTask, + hideText: true, + } + + // 按钮 + menuButton!: MenuButton; + // 提示 + tippyInstance!: Instance; + + /** + * 定义初始化方法,创建一个按钮 + */ + constructor() { + super(); + this.menuButton = new MenuButton(this.menuButtonOptions); + } + + /** + * 定义创建方法 + * @param event + * @param options + */ + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + // 初始化变量信息 + this.editor = event.editor; + this.menuButton.onCreate(event, options); + // 界面上添加当前按钮 + this.appendChild(this.menuButton); + + // 定义按钮的提示信息 + if (this.menuButtonOptions.enable && this.menuButton.menuButtonArrow) { + this.tippyInstance = tippy(this.menuButton.menuButtonArrow, { + content: this.createContainer(event, options), + placement: 'bottom', + trigger: 'click', + interactive: true, + arrow: false, + onShow: () => { + this.menuButton.tippyInstance?.disable(); + }, + onHidden: () => { + this.menuButton.tippyInstance?.enable(); + }, + }) + } + } + + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + } + + onEditableChange(editable: boolean) { + } + + /** + * 定义容器的创建方法 + * @param event + * @param options + * @returns + */ + createContainer(event: EditorEvents["create"], options: UAIEditorOptions) { + // 容器DIV + const container = document.createElement("div"); + container.classList.add("uai-popup-action-list") + + // 在DIV中添加所有自定义的Action + this.aiActions?.forEach(action => { + const item = document.createElement("div"); + item.classList.add("uai-popup-action-item") + item.innerHTML = `${action.icon ?? ""} ${action.name}`; + // 定义点击事件 + item.addEventListener('click', () => { + // 调用Action定义个点击事件 + action.action?.call(this); + this.tippyInstance.hide(); + }); + container.appendChild(item); + }) + + return container + } +} + +/** + * 定义对话界面中用户请求内容的功能菜单 + */ +export class AIRequestAction extends AbstractAIAction { + sourceDivElement!: HTMLElement + sourceInputElement!: HTMLElement + + // 默认的用户请求功能菜单项 + defaultAIActions: AICommand[] = [ + { + // 编辑用户发送的请求内容 + icon: Icons.EditAction, + name: `编辑`, + action: () => { + (this.sourceInputElement as HTMLTextAreaElement).value = this.sourceDivElement.innerText + this.sourceInputElement.style.height = 'auto' + this.sourceInputElement.style.height = this.sourceInputElement.scrollHeight + 'px' + } + }, + { + // 追加内容到编辑器的当前光标位置 + icon: Icons.InsertAction, + name: `追加`, + action: () => { + const { + state: { selection, tr }, + view: { dispatch } + } = this.editor; + const content = (this.editor as InnerEditor).parseHtml(markdownToHtml(this.sourceDivElement.getAttribute("InnerContent")!)); + dispatch(tr.replace(selection.to, selection.to, new Slice(content, 0, 0)).scrollIntoView()) + } + }, + { + // 替换编辑器中选中的内容 + icon: Icons.ReplaceAction, + name: `替换`, + action: () => { + const { + state: { tr }, + view: { dispatch } + } = this.editor + const content = (this.editor as InnerEditor).parseHtml(markdownToHtml(this.sourceDivElement.getAttribute("InnerContent")!)); + dispatch(tr.replaceSelection(new Slice(content, 0, 0)).scrollIntoView()) + } + } + ] + + constructor() { + super() + this.aiActions = this.defaultAIActions.map((menu) => { + return { + ...menu, + name: `${t(menu.name)}` + } + }) + } + + bindSourceElement(sourceDivElement: HTMLElement, sourceInputElement: HTMLElement) { + this.sourceDivElement = sourceDivElement + this.sourceInputElement = sourceInputElement + } +} + +/** + * 定义对话界面中响应数据的功能菜单 + */ +export class AIChatResponseAction extends AbstractAIAction { + sourceDivElement!: HTMLElement + sourceInputElement!: HTMLElement + + // 默认的菜单项 + defaultAIActions: AICommand[] = [ + { + // 追加内容到编辑器的当前光标位置 + icon: Icons.InsertAction, + name: `追加`, + action: () => { + const { + state: { selection, tr }, + view: { dispatch } + } = this.editor; + const content = (this.editor as InnerEditor).parseHtml(markdownToHtml(this.sourceDivElement.getAttribute("InnerContent")!)); + dispatch(tr.replace(selection.to, selection.to, new Slice(content, 0, 0)).scrollIntoView()) + } + }, + { + // 替换编辑器中选中的内容 + icon: Icons.ReplaceAction, + name: `替换`, + action: () => { + const { + state: { tr }, + view: { dispatch } + } = this.editor + const content = (this.editor as InnerEditor).parseHtml(markdownToHtml(this.sourceDivElement.getAttribute("InnerContent")!)); + dispatch(tr.replaceSelection(new Slice(content, 0, 0)).scrollIntoView()) + } + } + ] + + constructor() { + super() + this.aiActions = this.defaultAIActions.map((menu) => { + return { + ...menu, + name: `${t(menu.name)}` + } + }) + } + + bindSourceElement(sourceDivElement: HTMLElement, sourceInputElement: HTMLElement) { + this.sourceDivElement = sourceDivElement + this.sourceInputElement = sourceInputElement + } +} diff --git a/src/components/containers/ChatContainer.ts b/src/components/containers/ChatContainer.ts new file mode 100644 index 0000000..cdb581a --- /dev/null +++ b/src/components/containers/ChatContainer.ts @@ -0,0 +1,323 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import closeIcon from "../../assets/icons/close.svg"; +import titleIcon from "../../assets/icons/chat-bot.svg"; +import sendIcon from "../../assets/icons/send-message.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions } from "../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; +import { createAvatar } from "../../utils/CreateAvatar.ts"; +import { Icons } from "../Icons.ts"; +import OpenAI from "openai"; +import { AIChatConfig } from "../../ai/config/AIConfig.ts"; +import { markdownToHtml } from "../../utils/MarkdownUtil.ts"; +import { AIRequestAction, AIChatResponseAction } from "../ai/AIAction.ts"; + +/** + * 定义聊天对话界面的容器 + */ +export class ChatContainer extends HTMLElement implements UAIEditorEventListener { + // 容器元素 + container!: HTMLElement; + + // 标题元素 + chatTitle!: HTMLElement; + + // 内容元素 + content!: HTMLElement; + + // 文本输入框 + messageTextarea!: HTMLTextAreaElement; + imageButton!: HTMLButtonElement; + modelSelect!: HTMLSelectElement; + + client?: OpenAI; + modelConfig?: AIChatConfig; + + event!: EditorEvents["create"]; + options!: UAIEditorOptions; + + constructor() { + super(); + // 初始化容器 + this.container = document.createElement("div"); + this.container.classList.add("uai-pannel-container"); + this.appendChild(this.container); + + // 初始化标题 + this.chatTitle = document.createElement("div"); + this.chatTitle.classList.add("uai-pannel-title"); + this.chatTitle.innerHTML = `  ${t('ai.chat.title')}` + this.container.appendChild(this.chatTitle); + + // 初始化关闭按钮 + const dialogClose = document.createElement("div"); + dialogClose.classList.add("uai-pannel-close"); + dialogClose.innerHTML = ``; + // 点击关闭按钮时关闭容器 + dialogClose.addEventListener("click", () => { + this.style.display = "none"; + }) + this.chatTitle.appendChild(dialogClose); + + // 初始化对话模型选择 + const modelSelectDiv = document.createElement('div'); + modelSelectDiv.classList.add('uai-container-app-models'); + + const modelSelectText = document.createElement('div'); + modelSelectText.innerText = "模型:"; + modelSelectDiv.appendChild(modelSelectText); + + this.modelSelect = document.createElement('select'); + this.modelSelect.classList.add('uai-model-select'); + modelSelectDiv.appendChild(this.modelSelect); + this.container.appendChild(modelSelectDiv); + + // 初始化对话内容容器 + this.content = document.createElement('div'); + this.content.classList.add('uai-ai-content'); + this.container.appendChild(this.content); + + // 定义一个监听,当聊天内容有变动时,聊天内容容器滚动到最底部 + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'childList' || mutation.type === 'characterData') { + this.content.scrollTop = this.content.scrollHeight; + } + }) + }) + // 配置MutationObserver + const config = { childList: true, characterData: true }; + + // 开始监听div的变化 + observer.observe(this.content, config); + + // 定义对话输入框组件 + const divAppInput = document.createElement('div'); + divAppInput.classList.add('uai-container-app-input'); + + const divMessageContainer = document.createElement('div'); + divMessageContainer.classList.add('uai-message-container'); + + this.messageTextarea = document.createElement('textarea'); + this.messageTextarea.classList.add('uai-message-textarea'); + this.messageTextarea.rows = 1; + this.messageTextarea.placeholder = '请输入内容... Shift+Enter换行;Enter发送'; + + // 定义键盘事件,Enter发送数据 + this.messageTextarea.addEventListener('keydown', (event) => { + this.handleKeyDown(event); + }) + // 定义输入事件,输入框自动根据输入内容调整大小 + this.messageTextarea.addEventListener('input', () => { + this.messageTextarea.style.height = 'auto'; + this.messageTextarea.style.height = this.messageTextarea.scrollHeight + 'px'; + }) + divMessageContainer.appendChild(this.messageTextarea); + + // 初始化发送按钮 + this.imageButton = document.createElement('button'); + this.imageButton.classList.add('uai-image-button'); + this.imageButton.innerHTML = ``; + this.imageButton.addEventListener('click', () => { + this.sendMessage(this.messageTextarea.value) + }) + divMessageContainer.appendChild(this.imageButton); + + divAppInput.appendChild(divMessageContainer); + this.container.appendChild(divAppInput); + } + + /** + * 定义创建方法 + * @param event + * @param options + */ + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + this.event = event; + this.options = options; + + // 初始化模型对话客户端 + if (options.ai && options.ai.chat?.models) { + // 初始化所有可选对话模型 + for (let model of Object.keys(options.ai.chat?.models)) { + const option = document.createElement('option'); + option.textContent = model; + this.modelSelect.appendChild(option); + } + // 获取当前选中的模型 + const model = this.modelSelect.selectedOptions[0].value; + // 获取选中的模型的配置信息 + this.modelConfig = options.ai!.chat!.models![model]; + // 根据模型配置信息初始化一个客户端 + this.client = new OpenAI({ + baseURL: this.modelConfig.baseUrl, + apiKey: this.modelConfig.apiKey, + dangerouslyAllowBrowser: true, + }); + // 定义模型选择的改变事件,当重新选择了对话模型,则需要重新创建一个客户端 + this.modelSelect.addEventListener("change", () => { + // 获取当前选中的模型 + const model = this.modelSelect.selectedOptions[0].value; + // 获取选中的模型的配置信息 + this.modelConfig = options.ai!.chat!.models![model]; + // 根据模型配置信息初始化一个客户端 + this.client = new OpenAI({ + baseURL: this.modelConfig.baseUrl, + apiKey: this.modelConfig.apiKey, + dangerouslyAllowBrowser: true, + }); + }) + } + } + + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + } + + onEditableChange(editable: boolean) { + } + + handleKeyDown(event: KeyboardEvent) { + // 检查是否按下了 Shift 和 Enter 键 + if (event.shiftKey && event.key === 'Enter') { + // 如果是 Shift + Enter,允许默认行为(通常是换行) + return; + } + // 检查是否按下了 Enter 键 + if (event.key === 'Enter') { + // 阻止默认的换行行为 + event.preventDefault(); + // 处理发送消息的逻辑 + this.sendMessage(this.messageTextarea.value); + } + } + + /** + * 定义发送消息的方法 + * @param message + */ + sendMessage(message: string) { + if (message.trim() !== '') { + // 创建用户消息容器 + const userMessageDiv = document.createElement('div'); + userMessageDiv.classList.add('message'); + userMessageDiv.classList.add('message-sent'); + + // 创建用户发送消息容器 + const userBubbleDiv = document.createElement('div'); + userBubbleDiv.classList.add('markdown-body'); + userBubbleDiv.classList.add('bubble'); + userBubbleDiv.classList.add('bubble-sent'); + userBubbleDiv.innerHTML = markdownToHtml(message); + userBubbleDiv.setAttribute("InnerContent", message); + + // 创建用户输入的功能按钮 + const userActionDown = document.createElement( + 'uai-editor-ai-action-request-action' + ) as AIRequestAction; + userActionDown.classList.add('hidden-content'); + userActionDown.onCreate(this.event, this.options); + userActionDown.bindSourceElement(userBubbleDiv, this.messageTextarea); + + // 创建用户头像容器 + const userAvatarDiv = createAvatar(Icons.UserAvatar); + userAvatarDiv.classList.add('user-avatar'); + + // 用户消息容器中,添加功能按钮 + userMessageDiv.appendChild(userActionDown); + // 用户消息容器中,添加用户发送内容 + userMessageDiv.appendChild(userBubbleDiv); + // 用户消息容器中,添加用户头像 + userMessageDiv.appendChild(userAvatarDiv); + + // 展示用户发送的消息 + this.content.appendChild(userMessageDiv); + + // 清空消息输入框 + this.messageTextarea.value = ''; + this.messageTextarea.style.height = 'auto'; + this.messageTextarea.style.height = this.messageTextarea.scrollHeight + 'px'; + this.messageTextarea.focus(); + + // 发送消息并等待响应 + this.receiveResponse(message); + } + } + + /** + * 定义接收模型响应的方法 + * @param message + */ + receiveResponse(message: string) { + // 创建模型响应消息容器 + const botResponseDiv = document.createElement('div'); + botResponseDiv.classList.add('message'); + botResponseDiv.classList.add('message-received'); + + // 定义模型头像容器 + const botAvatarDiv = createAvatar(Icons.BotAvatar); + botAvatarDiv.classList.add('bot-avatar'); + + // 定义模型输出内容容器 + const botBubbleDiv = document.createElement('div'); + botBubbleDiv.classList.add('markdown-body'); + botBubbleDiv.classList.add('bubble'); + botBubbleDiv.classList.add('bubble-received'); + botBubbleDiv.innerHTML = Icons.Loading; + + // 定义模型输出的功能按钮 + const botActionDown = document.createElement( + 'uai-editor-ai-action-chat-response-action' + ) as AIChatResponseAction; + botActionDown.classList.add('hidden-content'); + botActionDown.onCreate(this.event, this.options); + botActionDown.bindSourceElement(botBubbleDiv, this.messageTextarea); + + // 在模型响应消息容器中,添加头像 + botResponseDiv.appendChild(botAvatarDiv); + // 在模型响应消息容器中,添加模型响应消息内容 + botResponseDiv.appendChild(botBubbleDiv); + // 在模型响应消息容器中,添加功能按钮 + botResponseDiv.appendChild(botActionDown); + + this.content.appendChild(botResponseDiv); + + // 发起对话请求,并接收模型响应内容 + if (this.client && this.modelConfig) { + const textQueue: string[] = []; + const stream = this.modelConfig.modelType === "LMDeploy" ? false : true; + // 通过客户端发起请求 + this.client.chat.completions.create({ + model: this.modelConfig.model ?? "o1", + stream: stream, + max_tokens: this.modelConfig.max_tokens, + temperature: this.modelConfig.temperature, + top_p: this.modelConfig.top_p, + frequency_penalty: this.modelConfig.frequency_penalty, + messages: [ + { + "role": "user", + "content": message + } + ], + }).then(async response => { + // 接收模型响应数据 + if (stream) { + // 流式响应 + for await (var chunk of response) { + textQueue.push(chunk.choices[0]?.delta?.content || ''); + botBubbleDiv.innerHTML = markdownToHtml(textQueue.join('')); + this.content.scrollTop = this.content.scrollHeight; + } + } else { + // 非流式响应 + textQueue.push(response.choices[0]?.message?.content || ''); + botBubbleDiv.innerHTML = markdownToHtml(textQueue.join('')); + this.content.scrollTop = this.content.scrollHeight; + } + botBubbleDiv.setAttribute("InnerContent", textQueue.join('')); + }); + } + } +} diff --git a/src/components/index.ts b/src/components/index.ts index f85975b..cf49fc0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ import { Header } from "../components/Header.ts"; import { Editor } from "../components/Editor.ts"; import { Footer } from "../components/Footer.ts"; +import { ChatContainer } from "../components/containers/ChatContainer.ts"; import { TocContainer } from "../components/containers/TocContainer.ts"; import { Ribbon } from "./menus/toolbar/Ribbon.ts"; @@ -86,6 +87,8 @@ import { ExportPdf } from "./menus/toolbar/export/ExportPdf.ts"; import { ExportMarkdown } from "./menus/toolbar/export/ExportMarkdown.ts"; import { ExportImage } from "./menus/toolbar/export/ExportImage.ts"; +import { ToggleChat } from "./menus/toolbar/ai/ToggleChat.ts"; + import { TextSelectionBubbleMenu } from "./menus/bubble/TextSelectionBubbleMenu.ts"; import { ImageBubbleMenu } from "./menus/bubble/ImageBubbleMenu.ts"; @@ -102,11 +105,14 @@ import { Fullscreen } from "./menus/statusbar/Fullscreen.ts"; import { Feedback } from "./menus/statusbar/Feedback.ts"; import { PoweredBy } from "./menus/statusbar/PoweredBy.ts"; +import { AIRequestAction, AIChatResponseAction } from "./ai/AIAction.ts"; + // 注册组件 defineCustomElement('uai-editor-header', Header); defineCustomElement('uai-editor-editor', Editor); defineCustomElement('uai-editor-footer', Footer); +defineCustomElement('uai-editor-chat-container', ChatContainer); defineCustomElement('uai-editor-toc-container', TocContainer); defineCustomElement('uai-editor-ribbon-menu', Ribbon); @@ -188,6 +194,8 @@ defineCustomElement('uai-editor-export-menu-pdf', ExportPdf); defineCustomElement('uai-editor-export-menu-markdown', ExportMarkdown); defineCustomElement('uai-editor-export-menu-image', ExportImage); +defineCustomElement('uai-editor-ai-menu-toggle-chat', ToggleChat); + defineCustomElement('uai-editor-statusbar-menu-character-count', CharacterCount); defineCustomElement('uai-editor-statusbar-menu-fullscreen', Fullscreen); @@ -203,3 +211,6 @@ defineCustomElement('uai-editor-bubble-menu-image-flip-y', ImageFlipY); defineCustomElement('uai-editor-bubble-menu-video', VideoBubbleMenu); defineCustomElement('uai-editor-bubble-menu-audio', AudioBubbleMenu); + +defineCustomElement('uai-editor-ai-action-request-action', AIRequestAction); +defineCustomElement('uai-editor-ai-action-chat-response-action', AIChatResponseAction); diff --git a/src/components/menus/toolbar/Classic.ts b/src/components/menus/toolbar/Classic.ts index 4ddda1e..354b9da 100644 --- a/src/components/menus/toolbar/Classic.ts +++ b/src/components/menus/toolbar/Classic.ts @@ -77,6 +77,8 @@ import { ExportPdf } from "./export/ExportPdf.ts"; import { ExportMarkdown } from "./export/ExportMarkdown.ts"; import { ExportImage } from "./export/ExportImage.ts"; +import { ToggleChat } from "./ai/ToggleChat.ts"; + /** * 传统菜单栏 */ @@ -112,6 +114,10 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { classicMenuExportScrollable!: ScrollableDiv; classicMenuExportGroup!: HTMLElement; + // 人工智能菜单容器 + classicMenuAIScrollable!: ScrollableDiv; + classicMenuAIGroup!: HTMLElement; + // 基础菜单 baseMenuUndo!: Undo; baseMenuRedo!: Redo; @@ -184,6 +190,9 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { exportMenuExportMarkdown!: ExportMarkdown; exportMenuExportImage!: ExportImage; + // 人工智能菜单 + aiMenuToggleChat!: ToggleChat; + constructor(defaultToolbarMenus: Record[]) { super(); this.defaultToolbarMenus = defaultToolbarMenus; @@ -233,6 +242,7 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { this.classicMenuToolScrollable.style.display = "none"; this.classicMenuPageScrollable.style.display = "none"; this.classicMenuExportScrollable.style.display = "none"; + this.classicMenuAIScrollable.style.display = "none"; if (menu === "base") { this.classicMenuBaseScrollable.style.display = "flex"; } @@ -251,6 +261,9 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { if (menu === "export") { this.classicMenuExportScrollable.style.display = "flex"; } + if (menu === "ai") { + this.classicMenuAIScrollable.style.display = "flex"; + } }) // 创建分组菜单 this.createBaseMenu(event, options); @@ -259,6 +272,7 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { this.createToolMenu(event, options); this.createPageMenu(event, options); this.createExportMenu(event, options); + this.createAIMenu(event, options); this.classicMenuBaseScrollable.style.display = "flex"; } @@ -450,6 +464,9 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { this.exportMenuExportImage = new ExportImage({ menuType: "button", enable: true, header: "classic", hideText: false }); this.eventComponents.push(this.exportMenuExportImage); + + this.aiMenuToggleChat = new ToggleChat({ menuType: "button", enable: true, header: "classic", hideText: false }); + this.eventComponents.push(this.aiMenuToggleChat); } /** * 创建基础菜单 @@ -650,4 +667,23 @@ export class Classic extends HTMLElement implements UAIEditorEventListener { this.classicMenuExportGroup.appendChild(group2); group2.appendChild(this.exportMenuExportImage); } + + /** + * 创建人工智能菜单 + * @param event + * @param options + */ + createAIMenu(event: EditorEvents["create"], options: UAIEditorOptions) { + this.classicMenuAIGroup = document.createElement("div"); + this.classicMenuAIGroup.style.display = "flex"; + this.classicMenuAIScrollable = new ScrollableDiv(this.classicMenuAIGroup); + this.classicMenuAIScrollable.style.display = "none"; + this.classicMenu.appendChild(this.classicMenuAIScrollable); + this.classicMenuAIScrollable.onCreate(event, options); + + const group1 = document.createElement("div"); + group1.classList.add("uai-classic-virtual-group"); + this.classicMenuAIGroup.appendChild(group1); + group1.appendChild(this.aiMenuToggleChat); + } } \ No newline at end of file diff --git a/src/components/menus/toolbar/Ribbon.ts b/src/components/menus/toolbar/Ribbon.ts index 8ef3436..bbcb7a0 100644 --- a/src/components/menus/toolbar/Ribbon.ts +++ b/src/components/menus/toolbar/Ribbon.ts @@ -79,6 +79,8 @@ import { ExportPdf } from "./export/ExportPdf.ts"; import { ExportMarkdown } from "./export/ExportMarkdown.ts"; import { ExportImage } from "./export/ExportImage.ts"; +import { ToggleChat } from "./ai/ToggleChat.ts"; + /** * 经典菜单栏 */ @@ -115,6 +117,10 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { ribbonMenuExportScrollable!: ScrollableDiv; ribbonMenuExportGroup!: HTMLElement; + // 人工智能菜单容器 + ribbonMenuAIScrollable!: ScrollableDiv; + ribbonMenuAIGroup!: HTMLElement; + // 基础菜单 baseMenuUndo!: Undo; baseMenuRedo!: Redo; @@ -187,6 +193,9 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { exportMenuExportMarkdown!: ExportMarkdown; exportMenuExportImage!: ExportImage; + // 人工智能菜单 + aiMenuToggleChat!: ToggleChat; + constructor(defaultToolbarMenus: Record[]) { super(); this.defaultToolbarMenus = defaultToolbarMenus; @@ -229,6 +238,7 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { this.ribbonMenuToolScrollable.style.display = "none"; this.ribbonMenuPageScrollable.style.display = "none"; this.ribbonMenuExportScrollable.style.display = "none"; + this.ribbonMenuAIScrollable.style.display = "none"; if (menu.value === "base") { this.ribbonMenuBaseScrollable.style.display = "flex"; } @@ -247,6 +257,9 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { if (menu.value === "export") { this.ribbonMenuExportScrollable.style.display = "flex"; } + if (menu.value === "ai") { + this.ribbonMenuAIScrollable.style.display = "flex"; + } }) ribbonTabs.appendChild(tab); }); @@ -270,6 +283,7 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { this.createToolMenu(event, options); this.createPageMenu(event, options); this.createExportMenu(event, options); + this.createAIMenu(event, options); this.ribbonMenuBaseScrollable.style.display = "flex"; } @@ -473,6 +487,9 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { this.exportMenuExportImage = new ExportImage({ menuType: "button", enable: true, huge: true, hideText: false }); this.eventComponents.push(this.exportMenuExportImage); + + this.aiMenuToggleChat = new ToggleChat({ menuType: "button", enable: true, huge: true, hideText: false }); + this.eventComponents.push(this.aiMenuToggleChat); } /** @@ -755,4 +772,23 @@ export class Ribbon extends HTMLElement implements UAIEditorEventListener { this.ribbonMenuExportGroup.appendChild(group2); group2.appendChild(this.exportMenuExportImage); } + + /** + * 创建人工智能菜单 + * @param event + * @param options + */ + createAIMenu(_event: EditorEvents["create"], _options: UAIEditorOptions) { + this.ribbonMenuAIGroup = document.createElement("div"); + this.ribbonMenuAIGroup.classList.add("uai-ribbon-container"); + this.ribbonMenuAIGroup.style.display = "flex"; + this.ribbonMenuAIScrollable = new ScrollableDiv(this.ribbonMenuAIGroup); + this.ribbonMenuAIScrollable.style.display = "none"; + this.ribbonScrollableContainer.appendChild(this.ribbonMenuAIScrollable); + + const group1 = document.createElement("div"); + group1.classList.add("uai-ribbon-virtual-group"); + this.ribbonMenuAIGroup.appendChild(group1); + group1.appendChild(this.aiMenuToggleChat); + } } \ No newline at end of file diff --git a/src/components/menus/toolbar/ai/ToggleChat.ts b/src/components/menus/toolbar/ai/ToggleChat.ts new file mode 100644 index 0000000..dcbf04e --- /dev/null +++ b/src/components/menus/toolbar/ai/ToggleChat.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2024-present AI-Labs + +// @ ts-nocheck +import { MenuButton, MenuButtonOptions } from "../../MenuButton.ts"; +import icon from "../../../../assets/icons/chat-bot.svg"; +import { t } from "i18next"; +import { UAIEditorEventListener, UAIEditorOptions, InnerEditor } from "../../../../core/UAIEditor.ts"; +import { EditorEvents } from "@tiptap/core"; + +/** + * 人工智能菜单:聊天 + */ +export class ToggleChat extends HTMLElement implements UAIEditorEventListener { + // 按钮选项 + menuButtonOptions: MenuButtonOptions = { + menuType: "button", + enable: true, + icon: icon, + hideText: false, + text: t('ai.chat.title'), + tooltip: t('ai.chat.title'), + } + + // 功能按钮 + menuButton: MenuButton; + + constructor(options: MenuButtonOptions) { + super(); + + // 初始化功能按钮选项 + this.menuButtonOptions = { ...this.menuButtonOptions, ...options }; + + // 创建功能按钮 + this.menuButton = new MenuButton(this.menuButtonOptions); + } + + /** + * 定义创建方法 + * @param event + * @param options + */ + onCreate(event: EditorEvents["create"], options: UAIEditorOptions) { + this.menuButton.onCreate(event, options); + this.appendChild(this.menuButton); + + // 定义按钮点击事件,打开人工智能聊天窗口 + this.addEventListener("click", () => { + if (this.menuButtonOptions.enable) { + // 判断聊天窗口是否已打开 + const display = (event.editor as InnerEditor).uaiEditor.chatContainer.style.display; + if (display != "none") { + // 如果窗口已打开,则关闭窗口 + (event.editor as InnerEditor).uaiEditor.chatContainer.style.display = "none"; + } else { + // 如果窗口没打开,则先关闭其他窗口,再打开聊天窗口 + (event.editor as InnerEditor).uaiEditor.toggleContainers.forEach(toggle => { + toggle.style.display = "none"; + }); + (event.editor as InnerEditor).uaiEditor.chatContainer.style.display = "block"; + } + } + }) + } + + /** + * 定义Transaction监听方法 + * @param event + * @param options + */ + onTransaction(event: EditorEvents["transaction"], options: UAIEditorOptions) { + this.menuButton.onTransaction(event, options); + } + + onEditableChange(editable: boolean) { + this.menuButtonOptions.enable = editable; + this.menuButton.onEditableChange(editable); + } +} + diff --git a/src/core/UAIEditor.ts b/src/core/UAIEditor.ts index 8552ebb..7b62628 100644 --- a/src/core/UAIEditor.ts +++ b/src/core/UAIEditor.ts @@ -12,6 +12,7 @@ import { TableOfContentData } from "@tiptap-pro/extension-table-of-contents"; import "../components" import { Header } from "../components/Header.ts"; +import { ChatContainer } from "../components/containers/ChatContainer.ts"; import { TocContainer } from "../components/containers/TocContainer.ts"; import { Editor } from "../components/Editor.ts"; import { Footer } from "../components/Footer.ts"; @@ -24,6 +25,7 @@ import i18next from "i18next"; import { zh } from "../i18n/zh.ts"; import { Resource } from "i18next"; import { allExtensions } from "./UAIExtensions.ts"; +import { AIChatConfig } from "../ai/config/AIConfig.ts"; import { markdownToHtml } from "../utils/MarkdownUtil.ts"; import { Uploader } from "../utils/FileUploader.ts"; @@ -105,6 +107,11 @@ export type UAIEditorOptions = { uploadFormName?: string, uploader?: Uploader, }, + ai?: { + chat?: { + models?: Record, + } + } } /** @@ -118,6 +125,15 @@ export class InnerEditor extends TipTap { super(options); this.uaiEditor = uaiEditor; } + + parseHtml(html: string) { + function bodyElement(value: string): HTMLElement { + return new window.DOMParser().parseFromString(`${value}`, 'text/html').body; + } + + const parser = DOMParser.fromSchema(this.schema); + return parser.parse(bodyElement(html), {}).content; + } } /** @@ -133,6 +149,7 @@ export class UAIEditor { toggleContainers: HTMLElement[] = []; header!: Header; + chatContainer!: ChatContainer; tocContainer!: TocContainer; editor!: Editor; footer!: Footer; @@ -231,6 +248,11 @@ export class UAIEditor { { label: '旗帜', value: '🏁 🎌 🏴󠁧󠁢󠁥󠁮󠁧󠁿', }, ], }, + ai: { + chat: { + models: customOptions.ai?.chat?.models, + }, + } }; const i18nConfig = this.options.i18n || {}; const resources = { @@ -268,6 +290,11 @@ export class UAIEditor { this.center.appendChild(this.tocContainer); this.toggleContainers.push(this.tocContainer); + this.chatContainer = new ChatContainer(); + this.chatContainer.style.display = "none"; + this.center.appendChild(this.chatContainer); + this.toggleContainers.push(this.chatContainer); + this.editor = new Editor(); this.editor.classList.add("uai-main"); this.center.appendChild(this.editor); @@ -330,6 +357,7 @@ export class UAIEditor { } this.header.onCreate(event, this.options); this.tocContainer.onCreate(event, this.options); + this.chatContainer.onCreate(event, this.options); this.editor.onCreate(event, this.options); this.footer.onCreate(event, this.options); this.eventComponents.forEach(component => { @@ -339,9 +367,10 @@ export class UAIEditor { protected onTransaction(event: EditorEvents['transaction']) { this.header.onTransaction(event, this.options); + this.tocContainer.onTransaction(event, this.options); + this.chatContainer.onTransaction(event, this.options); this.editor.onTransaction(event, this.options); this.footer.onTransaction(event, this.options); - this.tocContainer.onTransaction(event, this.options); this.eventComponents.forEach(component => { component.onTransaction(event, this.options); }); diff --git a/src/utils/CreateAvatar.ts b/src/utils/CreateAvatar.ts new file mode 100644 index 0000000..7a8a8f1 --- /dev/null +++ b/src/utils/CreateAvatar.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2024-present AI-Labs + +/** + * 创建头像 + * @param icon + * @returns + */ +export function createAvatar(icon: string) { + const avatarDiv = document.createElement('div') + avatarDiv.style.fontSize = '30px' + avatarDiv.style.marginLeft = '5px' + avatarDiv.innerHTML = icon + return avatarDiv +}