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
+}