diff --git a/packages/docs/fluent-editor/.vitepress/sidebar.ts b/packages/docs/fluent-editor/.vitepress/sidebar.ts index 41ec10ba..798bc2c8 100644 --- a/packages/docs/fluent-editor/.vitepress/sidebar.ts +++ b/packages/docs/fluent-editor/.vitepress/sidebar.ts @@ -26,6 +26,7 @@ export function sidebar() { { text: '只读模式', link: '/docs/demo/readonly' }, { text: '模拟语雀文档', link: 'https://opentiny.github.io/tiny-editor/projects' }, { text: '图片工具栏', link: '/docs/demo/image-tool' }, + { text: 'AI', link: '/docs/demo/ai' }, ], }, { diff --git a/packages/docs/fluent-editor/demos/ai.vue b/packages/docs/fluent-editor/demos/ai.vue new file mode 100644 index 00000000..ac487343 --- /dev/null +++ b/packages/docs/fluent-editor/demos/ai.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/docs/fluent-editor/docs/demo/ai.md b/packages/docs/fluent-editor/docs/demo/ai.md new file mode 100644 index 00000000..cd013c86 --- /dev/null +++ b/packages/docs/fluent-editor/docs/demo/ai.md @@ -0,0 +1,21 @@ +# AI + +通过模块配置AI。 + +:::demo src=demos/ai.vue +::: + +## API + +`ai` 模块配置项: + +```typescript +interface AIOption { + // AI大模型主机地址 + host: string + // 大模型名称 + model: string + // API 密钥 + apiKey: string +} +``` diff --git a/packages/fluent-editor/src/assets/ai.scss b/packages/fluent-editor/src/assets/ai.scss new file mode 100644 index 00000000..f872abf8 --- /dev/null +++ b/packages/fluent-editor/src/assets/ai.scss @@ -0,0 +1,101 @@ +.ql-ai-dialog { + position: absolute; + left: 15px; +} + +.ql-ai-wrapper { + position: relative; + width: 100%; + z-index: 1000; +} + +.ql-ai-tip { + flex-shrink: 0; +} + +.ql-ai-input { + display: flex; + align-items: center; + padding: 10px; + background: white; + border: 1px solid #c2c2c2; + border-radius: 4px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16); +} + +.ql-ai-icon { + width: 18px; + height: 18px; + margin-right: 8px; + display: flex; + align-items: center; + justify-content: center; +} + +.ql-ai-icon svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +.ql-ai-input input { + flex: 1; + padding: 8px; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +.ql-ai-result { + position: absolute; + bottom: calc(100% + 10px); + left: 0; + right: 0; + padding: 10px; + background: white; + border: 1px solid #c2c2c2; + border-radius: 4px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16); + max-height: 200px; + overflow-y: auto; +} + +.ql-ai-dropdown-button { + padding: 4px 8px; + background: #f0f0f0; + border: 1px solid #ddd; + cursor: pointer; +} + +.ql-ai-actions { + position: absolute; + top: calc(100% + 10px); + left: 0; + background: white; + border: 1px solid #c2c2c2; + border-radius: 4px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16); + z-index: 1001; + min-width: 100px; +} + +.ql-ai-action-item { + padding: 8px 12px; + cursor: pointer; + &:hover { + background: #f5f5f5; + } +} + +.ql-ai-send { + margin-left: 8px; + padding: 2px 8px; + background: #1890ff; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + &:hover { + background: #40a9ff; + } +} diff --git a/packages/fluent-editor/src/assets/style.scss b/packages/fluent-editor/src/assets/style.scss index b014aa07..6e8360d2 100644 --- a/packages/fluent-editor/src/assets/style.scss +++ b/packages/fluent-editor/src/assets/style.scss @@ -25,6 +25,7 @@ @import './fullscreen'; @import './screenshot'; @import './mathlive'; +@import './ai.scss'; // 模块:字数统计 counter @include counter; diff --git a/packages/fluent-editor/src/fluent-editor.ts b/packages/fluent-editor/src/fluent-editor.ts index 933fabdf..1738f9f3 100644 --- a/packages/fluent-editor/src/fluent-editor.ts +++ b/packages/fluent-editor/src/fluent-editor.ts @@ -3,6 +3,7 @@ import { EN_US } from './config/i18n/en-us' import { ZH_CN } from './config/i18n/zh-cn' import FluentEditor from './core/fluent-editor' import { EmojiBlot, SoftBreak, StrikeBlot, Video } from './formats' +import { AI } from './modules/ai' // AI import Counter from './modules/counter' // 字符统计 import { CustomClipboard } from './modules/custom-clipboard' // 粘贴板 import { BlotFormatter } from './modules/custom-image' // 图片 @@ -52,6 +53,7 @@ FluentEditor.register( 'modules/i18n': I18N, 'modules/image': BlotFormatter, 'modules/mathlive': MathliveModule, + 'modules/ai': AI, 'modules/mention': Mention, 'modules/syntax': Syntax, 'modules/toolbar': BetterToolbar, diff --git a/packages/fluent-editor/src/modules/ai/index.ts b/packages/fluent-editor/src/modules/ai/index.ts new file mode 100644 index 00000000..fa172fb2 --- /dev/null +++ b/packages/fluent-editor/src/modules/ai/index.ts @@ -0,0 +1,285 @@ +import type TypeToolbar from 'quill/modules/toolbar' +import type FluentEditor from '../../core/fluent-editor' +import { AI_ICON } from '../../ui/icons.config' + +export class AI { + toolbar: TypeToolbar + host: string + apiKey: string + model: string + message: string + isBreak: boolean = false // 打断标记 + inputValue: string = '' // 存储输入框的值 + SEND: string = '发送' // 发送按钮文字 + BREAK: string = '停止' // 取消按钮文字 + DONE: string = '完成' + REGENERATE: string = '重新生成' + CLOSE: string = '关闭' + private dialogContainerEl: HTMLDivElement | null = null + private wrapContainerEl: HTMLDivElement | null = null + private aiPreTextEl: HTMLSpanElement | null = null + private inputContainerEl: HTMLDivElement | null = null + private inputEl: HTMLInputElement | null = null + private sendButtonEl: HTMLDivElement | null = null + private resultPopupEl: HTMLDivElement | null = null + private actionMenuEl: HTMLDivElement | null = null + + constructor(public quill: FluentEditor, public options: any) { + this.quill = quill + this.toolbar = quill.getModule('toolbar') as TypeToolbar + // 添加AI按钮到工具栏 + if (typeof this.toolbar !== 'undefined') { + this.toolbar.addHandler('ai', this.showAIInput.bind(this)) + } + + this.host = options.host + this.apiKey = options.apiKey + this.model = options.model + } + + positionElements() { + if (!this.dialogContainerEl) return + const range = this.quill.getSelection() + if (range) { + const bounds = this.quill.getBounds(range.index) + this.dialogContainerEl.style.position = 'absolute' + this.dialogContainerEl.style.top = `${bounds.top + bounds.height}px` + } + } + + // 创建AI提示语元素 + private createAiPreTextEl() { + if (!this.aiPreTextEl) { + this.aiPreTextEl = document.createElement('span') + this.aiPreTextEl.className = 'ql-ai-tip' + } + } + + // 创建弹出框 + private createElements() { + if(!this.dialogContainerEl) { + this.dialogContainerEl = document.createElement('div') + this.dialogContainerEl.className = 'ql-ai-dialog' + this.wrapContainerEl = document.createElement('div') + this.wrapContainerEl.className = 'ql-ai-wrapper' + this.wrapContainerEl.style.width = `${this.quill.container.clientWidth * 0.9}px` + + // 创建输入框 + this.inputContainerEl = document.createElement('div') + this.inputContainerEl.className = 'ql-ai-input' + + // 添加AI图标 + const aiIcon = document.createElement('div') + aiIcon.className = 'ql-ai-icon' + aiIcon.innerHTML = AI_ICON + // 添加AI提示语 + this.createAiPreTextEl() + + // 增加输入框 + this.inputEl = document.createElement('input') + this.inputEl.type = 'text' + this.inputEl.placeholder = '请输入内容' + // 添加发送按钮 + this.sendButtonEl = document.createElement('div') + this.sendButtonEl.className = 'ql-ai-send' + + + // 创建结果弹窗 + this.resultPopupEl = document.createElement('div') + this.resultPopupEl.className = 'ql-ai-result' + + // 添加到编辑器 + this.wrapContainerEl.appendChild(this.resultPopupEl) + this.inputContainerEl.appendChild(aiIcon) + this.inputContainerEl.appendChild(this.aiPreTextEl) + this.inputContainerEl.appendChild(this.inputEl) + this.inputContainerEl.appendChild(this.sendButtonEl) // 添加发送按钮 + this.wrapContainerEl.appendChild(this.inputContainerEl) + this.dialogContainerEl.appendChild(this.wrapContainerEl) + } + + this.aiPreTextEl.textContent = `${this.model}帮你写:` + this.sendButtonEl.textContent = this.SEND + this.sendButtonEl.style.display = 'block' // 显示发送按钮 + this.resultPopupEl.style.display = 'none' + this.quill.container.appendChild(this.dialogContainerEl) + } + + showAIInput() { + // 创建输入框和结果弹窗 + this.createElements() + + // 定位到编辑器焦点位置 + this.positionElements() + + // 监听发送事件 + this.inputEl.addEventListener('keydown', async (e) => { + if (e.key === 'Enter') { + await this.queryAI() + } + }) + this.sendButtonEl.addEventListener('click', async (e) => { + if (e.target.textContent === this.BREAK) { + this.isBreak = true + } + else { + await this.queryAI() + } + }) + + // 添加ESC键监听 + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.closeAIPanel() + this.quill.container.removeEventListener('keydown', handleKeyDown) + } + } + this.quill.container.addEventListener('keydown', handleKeyDown) + + } + + // AI查询 + private async queryAI(question?: string): Promise { + this.inputValue = question || this.inputEl.value + this.inputEl.value = '' // 清空输入框 + if(this.inputValue.trim() === '') { + return + } + + // 有信息 + this.isBreak = false // 重置打断标记,防止重复打断ai + this.sendButtonEl.textContent = this.BREAK + this.sendButtonEl.style.display = 'block' + this.aiPreTextEl.textContent = '按ESC退出 | 正在编写...' // 显示提示语 + // 这里实现实际的AI查询逻辑 + try { + const response = await fetch(`${this.host}/api/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + prompt: this.inputValue, + stream: true, + }), + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let content = '' + + while (true) { + if (this.isBreak) { + this.isBreak = false + break + } + + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value) + const lines = chunk.split('\n').filter(line => line.trim() !== '') + + for (const line of lines) { + try { + const data = JSON.parse(line) + content += data.response || '' + this.showAIResponse(content) + } + catch (e) { + console.error('解析错误:', e) + } + } + } + + // 创建操作菜单 + this.createActionMenu() + if (content) { + this.aiPreTextEl.textContent = '' // 清空提示语 + // 隐藏发送按钮 + this.sendButtonEl.textContent = this.SEND + this.sendButtonEl.style.display = 'none' + } + return content + } + catch (error) { + console.error('AI查询失败:', error) + return 'AI查询失败,请重试' + } + } + + private showAIResponse(response: string) { + if (!this.resultPopupEl) return + + // 显示结果 + this.resultPopupEl.innerHTML = response + this.resultPopupEl.style.display = 'block' + } + + private createActionMenu() { + if (!this.actionMenuEl) { + this.actionMenuEl = document.createElement('div') + this.actionMenuEl.className = 'ql-ai-actions' + + const actions = [this.DONE, this.REGENERATE, this.CLOSE] + actions.forEach((action) => { + const menuItem = document.createElement('div') + menuItem.className = 'ql-ai-action-item' + menuItem.textContent = action + menuItem.addEventListener('click', () => this.handleAction(action)) + this.actionMenuEl.appendChild(menuItem) + }) + + this.wrapContainerEl.appendChild(this.actionMenuEl) + } + // 展示下拉菜单 + this.actionMenuEl.style.display = 'block' + } + + private handleAction(action: string) { + switch (action) { + case this.DONE: + this.insertAIResponse() + break + case this.REGENERATE: + this.regenerateResponse() + break + case this.CLOSE: + this.closeAIPanel() + break + } + } + + private insertAIResponse() { + if (!this.resultPopupEl) return + const range = this.quill.getSelection(true) + if (range) { + // 使用HTML方式插入可以保留格式 + this.quill.clipboard.dangerouslyPasteHTML( + range.index, + this.resultPopupEl.innerHTML, + ) + } + this.closeAIPanel() + } + + private async regenerateResponse() { + this.actionMenuEl.style.display = 'none' + await this.queryAI(this.inputValue) + } + + private closeAIPanel() { + this.isBreak = true // 停止查询 + if(this.dialogContainerEl) { + this.quill.container.removeChild(this.dialogContainerEl) + } + this.dialogContainerEl = null + this.actionMenuEl = null + } +} diff --git a/packages/fluent-editor/src/themes/snow.ts b/packages/fluent-editor/src/themes/snow.ts index 4d9bc998..af6ebb7e 100644 --- a/packages/fluent-editor/src/themes/snow.ts +++ b/packages/fluent-editor/src/themes/snow.ts @@ -59,6 +59,7 @@ OriginSnowTheme.DEFAULTS = { }) inputFile.call(this, 'video', accept) }, + 'ai': function () {}, 'emoji': function () {}, 'fullscreen': fullscreenHandler, [FormatPainter.toolName]: FormatPainter, diff --git a/packages/fluent-editor/src/ui/icons.config.ts b/packages/fluent-editor/src/ui/icons.config.ts index 6592b4b4..a898ac20 100644 --- a/packages/fluent-editor/src/ui/icons.config.ts +++ b/packages/fluent-editor/src/ui/icons.config.ts @@ -314,3 +314,18 @@ export const TRIANGLE_DOWN_ICON = `` export const FORMAT_PAINTER_ICON = `` export const DIVIDER_ICON = `` + +export const AI_ICON = ` + + + + + + + + + + + + +` diff --git a/packages/fluent-editor/src/ui/icons.ts b/packages/fluent-editor/src/ui/icons.ts index a23c2da1..3d0eb057 100644 --- a/packages/fluent-editor/src/ui/icons.ts +++ b/packages/fluent-editor/src/ui/icons.ts @@ -1,5 +1,6 @@ import FluentEditor from '../core/fluent-editor' import { + AI_ICON, ALIGN_CENTER_ICON, ALIGN_JUSTIFY_ICON, ALIGN_LEFT_ICON, @@ -36,6 +37,7 @@ const ICONS_CONFIG: { [key: string]: any } = { 'undo': UNDO_ICON, 'redo': REDO_ICON, 'clean': CLEAN_ICON, + 'ai': AI_ICON, 'bold': BOLD_ICON, 'italic': ITALIC_ICON,