From 6c03f2ef133292acc1f1dfdcbeb716fd97a6732a Mon Sep 17 00:00:00 2001 From: chenxi <2465950588@qq.com> Date: Wed, 16 Apr 2025 02:31:56 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0AI=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/fluent-editor/.vitepress/sidebar.ts | 1 + packages/docs/fluent-editor/demos/ai.vue | 40 +++ packages/docs/fluent-editor/docs/demo/ai.md | 6 + packages/fluent-editor/package.json | 1 + packages/fluent-editor/src/assets/ai.scss | 101 +++++++ packages/fluent-editor/src/assets/style.scss | 1 + packages/fluent-editor/src/fluent-editor.ts | 2 + .../fluent-editor/src/modules/ai/index.ts | 246 ++++++++++++++++++ packages/fluent-editor/src/themes/snow.ts | 1 + packages/fluent-editor/src/ui/icons.config.ts | 6 + packages/fluent-editor/src/ui/icons.ts | 2 + 11 files changed, 407 insertions(+) create mode 100644 packages/docs/fluent-editor/demos/ai.vue create mode 100644 packages/docs/fluent-editor/docs/demo/ai.md create mode 100644 packages/fluent-editor/src/assets/ai.scss create mode 100644 packages/fluent-editor/src/modules/ai/index.ts diff --git a/packages/docs/fluent-editor/.vitepress/sidebar.ts b/packages/docs/fluent-editor/.vitepress/sidebar.ts index d82e1e23..408bb11a 100644 --- a/packages/docs/fluent-editor/.vitepress/sidebar.ts +++ b/packages/docs/fluent-editor/.vitepress/sidebar.ts @@ -25,6 +25,7 @@ export function sidebar() { { text: '工具栏提示', link: '/docs/demo/toolbar-tip' }, { text: '只读模式', link: '/docs/demo/readonly' }, { text: '模拟语雀文档', link: 'https://opentiny.github.io/tiny-editor/projects' }, + { 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..514285ff --- /dev/null +++ b/packages/docs/fluent-editor/demos/ai.vue @@ -0,0 +1,40 @@ + + + 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..326e81c1 --- /dev/null +++ b/packages/docs/fluent-editor/docs/demo/ai.md @@ -0,0 +1,6 @@ +# AI + +通过工具栏配置AI。 + +:::demo src=demos/ai.vue +::: diff --git a/packages/fluent-editor/package.json b/packages/fluent-editor/package.json index e309aa8a..1c02b18b 100644 --- a/packages/fluent-editor/package.json +++ b/packages/fluent-editor/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "lodash-es": "^4.17.15", + "ollama": "0.5.14", "quill": "^2.0.0", "quill-easy-color": "^0.0.9", "quill-shortcut-key": "^0.0.5" 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 46e7da0b..444f24fd 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 { 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' // 图片 @@ -53,6 +54,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..fe3a019d --- /dev/null +++ b/packages/fluent-editor/src/modules/ai/index.ts @@ -0,0 +1,246 @@ +import type TypeToolbar from 'quill/modules/toolbar' +import type FluentEditor from '../../core/fluent-editor' +import { Ollama } from 'ollama' +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 = '' // 存储输入框的值 + private dialogContainer: HTMLDivElement | null = null + private wrapContainer: HTMLDivElement | null = null + private aiPreTextEl: HTMLSpanElement | null = null + private inputContainer: HTMLDivElement | null = null + private inputEl: HTMLTextAreaElement | null = null + private sendButtonEl: HTMLDivElement | null = null + private resultPopup: HTMLDivElement | null = null + private actionMenu: 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.dialogContainer) return + const range = this.quill.getSelection() + if (range) { + const bounds = this.quill.getBounds(range.index) + this.dialogContainer.style.position = 'absolute' + this.dialogContainer.style.top = `${bounds.top + bounds.height}px` + } + } + + private operateSendButtonEl(text: string = '发送', isShow: boolean = true) { + // 添加发送按钮 + if (!this.sendButtonEl) { + this.sendButtonEl = document.createElement('div') + this.sendButtonEl.className = 'ql-ai-send' + } + this.sendButtonEl.innerText = text + this.sendButtonEl.style.display = isShow ? 'block' : 'none' // 显示发送按钮 + } + + // 创建AI提示语元素 + private createAiPreTextEl() { + if (!this.aiPreTextEl) { + this.aiPreTextEl = document.createElement('span') + this.aiPreTextEl.className = 'ql-ai-tip' + } + this.aiPreTextEl.innerText = `${this.model}帮你写:` + } + + showAIInput() { + // 创建输入框和结果弹窗 + this.dialogContainer = document.createElement('div') + this.dialogContainer.className = 'ql-ai-dialog' + + this.wrapContainer = document.createElement('div') + this.wrapContainer.className = 'ql-ai-wrapper' + this.wrapContainer.style.width = `${this.quill.container.clientWidth * 0.9}px` + + // 创建输入框 + this.inputContainer = document.createElement('div') + this.inputContainer.className = 'ql-ai-input' + + // 添加AI图标 + const aiIcon = document.createElement('div') + aiIcon.className = 'ql-ai-icon' + aiIcon.innerHTML = AI_ICON + + // 添加AI提示语 + this.createAiPreTextEl() + + // 替换input为textarea + this.inputEl = document.createElement('input') + this.inputEl.type = 'text' + this.inputEl.placeholder = '请输入内容' + + this.operateSendButtonEl() + + // 创建结果弹窗 + this.resultPopup = document.createElement('div') + this.resultPopup.className = 'ql-ai-result' + this.resultPopup.style.display = 'none' + + // 添加到编辑器 + this.wrapContainer.appendChild(this.resultPopup) + this.inputContainer.appendChild(aiIcon) + this.inputContainer.appendChild(this.aiPreTextEl) + this.inputContainer.appendChild(this.inputEl) + this.inputContainer.appendChild(this.sendButtonEl) // 添加发送按钮 + this.wrapContainer.appendChild(this.inputContainer) + this.dialogContainer.appendChild(this.wrapContainer) + this.quill.container.appendChild(this.dialogContainer) + + // 定位到编辑器焦点位置 + this.positionElements() + + // 监听输入事件 + this.inputEl.addEventListener('keydown', async (e) => { + if (e.key === 'Enter') { + this.inputValue = this.inputEl.value + this.inputEl.value = '' // 清空输入框 + await this.queryAI() + } + }) + this.sendButtonEl.addEventListener('click', async (e) => { + if (e.target.innerText === '停止') { + this.isBreak = true + } + else { + this.inputValue = this.inputEl.value + this.inputEl.value = '' // 清空输入框 + await this.queryAI() + } + }) + } + + private async queryAI(): Promise { + this.operateSendButtonEl('停止') + this.aiPreTextEl.innerText = '正在编写...' + this.inputEl.value = '' + // 这里实现实际的AI查询逻辑 + try { + console.log('this.inputValue', this.inputValue) + const ollama = new Ollama({ host: this.host }) + const res = await ollama.generate({ + model: this.model, + prompt: this.inputValue, + stream: true, + }) + let content = '' + for await (const chunk of res) { + if (this.isBreak) { + this.isBreak = false + break + } + content += chunk.response + this.showAIResponse(content) + } + // 创建操作菜单 + this.createActionMenu() + if (content) { + this.aiPreTextEl.innerText = '' // 清空提示语 + this.operateSendButtonEl('发送', false) // 隐藏发送按钮 + } + } + catch (error) { + console.error('AI查询失败:', error) + return 'AI查询失败,请重试' + } + } + + private showAIResponse(response: string) { + if (!this.resultPopup || !this.inputContainer) return + + // 显示结果 + this.resultPopup.innerHTML = response + this.resultPopup.style.display = 'block' + } + + private createActionMenu() { + if (this.actionMenu) return + + this.actionMenu = document.createElement('div') + this.actionMenu.className = 'ql-ai-actions' + this.actionMenu.style.display = 'none' // 初始隐藏 + + const actions = ['完成', '重新生成', '关闭'] + actions.forEach((action) => { + const menuItem = document.createElement('div') + menuItem.className = 'ql-ai-action-item' + menuItem.textContent = action + menuItem.addEventListener('click', () => this.handleAction(action)) + this.actionMenu?.appendChild(menuItem) + }) + + this.wrapContainer.appendChild(this.actionMenu) + // 展示下拉菜单 + this.actionMenu!.style.display = 'block' + } + + private handleAction(action: string) { + switch (action) { + case '完成': + this.insertAIResponse() + break + case '重新生成': + this.regenerateResponse() + break + case '关闭': + this.closeAIPanel() + break + } + } + + private insertAIResponse() { + if (!this.resultPopup) return + const range = this.quill.getSelection(true) + if (range) { + // 使用HTML方式插入可以保留格式 + this.quill.clipboard.dangerouslyPasteHTML( + range.index, + this.resultPopup.innerHTML, + ) + // 将光标移动到插入内容的末尾 + this.quill.setSelection( + range.index + this.resultPopup.innerHTML.length, + 0, + 'api', + ) + } + this.closeAIPanel() + } + + private async regenerateResponse() { + this.wrapContainer.removeChild(this.actionMenu) + this.actionMenu = null + if (this.inputValue) { + this.inputEl.value = '' // 清空输入框 + await this.queryAI() + } + } + + private closeAIPanel() { + if (this.dialogContainer) { + this.quill.container.removeChild(this.dialogContainer) + this.dialogContainer = null + this.inputContainer = null + this.resultPopup = null + this.actionMenu = null + } + } +} diff --git a/packages/fluent-editor/src/themes/snow.ts b/packages/fluent-editor/src/themes/snow.ts index e1882097..4dfd4601 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 45f70c08..e87200af 100644 --- a/packages/fluent-editor/src/ui/icons.config.ts +++ b/packages/fluent-editor/src/ui/icons.config.ts @@ -306,3 +306,9 @@ 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, From 2d7b1a7cfdb04c9338980a00565a309084058735 Mon Sep 17 00:00:00 2001 From: chenxi <2465950588@qq.com> Date: Thu, 17 Apr 2025 00:48:35 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E5=8E=BB=E6=8E=89ollama=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E4=BC=98=E5=8C=96dom=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/docs/fluent-editor/demos/ai.vue | 1 + packages/fluent-editor/package.json | 1 - .../fluent-editor/src/modules/ai/index.ts | 285 ++++++++++-------- 3 files changed, 162 insertions(+), 125 deletions(-) diff --git a/packages/docs/fluent-editor/demos/ai.vue b/packages/docs/fluent-editor/demos/ai.vue index 514285ff..ac487343 100644 --- a/packages/docs/fluent-editor/demos/ai.vue +++ b/packages/docs/fluent-editor/demos/ai.vue @@ -2,6 +2,7 @@ import type FluentEditor from '@opentiny/fluent-editor' import { onMounted } from 'vue' +let editor: FluentEditor const TOOLBAR_CONFIG = [ ['ai', { header: [] }], ['bold', 'italic', 'underline', 'link'], diff --git a/packages/fluent-editor/package.json b/packages/fluent-editor/package.json index 1c02b18b..e309aa8a 100644 --- a/packages/fluent-editor/package.json +++ b/packages/fluent-editor/package.json @@ -33,7 +33,6 @@ }, "dependencies": { "lodash-es": "^4.17.15", - "ollama": "0.5.14", "quill": "^2.0.0", "quill-easy-color": "^0.0.9", "quill-shortcut-key": "^0.0.5" diff --git a/packages/fluent-editor/src/modules/ai/index.ts b/packages/fluent-editor/src/modules/ai/index.ts index fe3a019d..cb8f5711 100644 --- a/packages/fluent-editor/src/modules/ai/index.ts +++ b/packages/fluent-editor/src/modules/ai/index.ts @@ -1,6 +1,5 @@ import type TypeToolbar from 'quill/modules/toolbar' import type FluentEditor from '../../core/fluent-editor' -import { Ollama } from 'ollama' import { AI_ICON } from '../../ui/icons.config' export class AI { @@ -11,14 +10,19 @@ export class AI { message: string isBreak: boolean = false // 打断标记 inputValue: string = '' // 存储输入框的值 - private dialogContainer: HTMLDivElement | null = null - private wrapContainer: HTMLDivElement | null = null + 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 inputContainer: HTMLDivElement | null = null - private inputEl: HTMLTextAreaElement | null = null + private inputContainerEl: HTMLDivElement | null = null + private inputEl: HTMLInputElement | null = null private sendButtonEl: HTMLDivElement | null = null - private resultPopup: HTMLDivElement | null = null - private actionMenu: HTMLDivElement | null = null + private resultPopupEl: HTMLDivElement | null = null + private actionMenuEl: HTMLDivElement | null = null constructor(public quill: FluentEditor, public options: any) { this.quill = quill @@ -34,128 +38,175 @@ export class AI { } positionElements() { - if (!this.dialogContainer) return + if (!this.dialogContainerEl) return const range = this.quill.getSelection() if (range) { const bounds = this.quill.getBounds(range.index) - this.dialogContainer.style.position = 'absolute' - this.dialogContainer.style.top = `${bounds.top + bounds.height}px` + this.dialogContainerEl.style.position = 'absolute' + this.dialogContainerEl.style.top = `${bounds.top + bounds.height}px` } } - private operateSendButtonEl(text: string = '发送', isShow: boolean = true) { - // 添加发送按钮 - if (!this.sendButtonEl) { - this.sendButtonEl = document.createElement('div') - this.sendButtonEl.className = 'ql-ai-send' - } - this.sendButtonEl.innerText = text - this.sendButtonEl.style.display = isShow ? 'block' : 'none' // 显示发送按钮 - } - // 创建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.innerText = `${this.model}帮你写:` + this.sendButtonEl.innerText = this.SEND + this.sendButtonEl.style.display = 'block' // 显示发送按钮 + this.resultPopupEl.style.display = 'none' + this.quill.container.appendChild(this.dialogContainerEl) } showAIInput() { // 创建输入框和结果弹窗 - this.dialogContainer = document.createElement('div') - this.dialogContainer.className = 'ql-ai-dialog' - - this.wrapContainer = document.createElement('div') - this.wrapContainer.className = 'ql-ai-wrapper' - this.wrapContainer.style.width = `${this.quill.container.clientWidth * 0.9}px` - - // 创建输入框 - this.inputContainer = document.createElement('div') - this.inputContainer.className = 'ql-ai-input' - - // 添加AI图标 - const aiIcon = document.createElement('div') - aiIcon.className = 'ql-ai-icon' - aiIcon.innerHTML = AI_ICON - - // 添加AI提示语 - this.createAiPreTextEl() - - // 替换input为textarea - this.inputEl = document.createElement('input') - this.inputEl.type = 'text' - this.inputEl.placeholder = '请输入内容' - - this.operateSendButtonEl() - - // 创建结果弹窗 - this.resultPopup = document.createElement('div') - this.resultPopup.className = 'ql-ai-result' - this.resultPopup.style.display = 'none' - - // 添加到编辑器 - this.wrapContainer.appendChild(this.resultPopup) - this.inputContainer.appendChild(aiIcon) - this.inputContainer.appendChild(this.aiPreTextEl) - this.inputContainer.appendChild(this.inputEl) - this.inputContainer.appendChild(this.sendButtonEl) // 添加发送按钮 - this.wrapContainer.appendChild(this.inputContainer) - this.dialogContainer.appendChild(this.wrapContainer) - this.quill.container.appendChild(this.dialogContainer) + this.createElements() // 定位到编辑器焦点位置 this.positionElements() - - // 监听输入事件 + + // 监听发送事件 this.inputEl.addEventListener('keydown', async (e) => { if (e.key === 'Enter') { - this.inputValue = this.inputEl.value - this.inputEl.value = '' // 清空输入框 await this.queryAI() } }) this.sendButtonEl.addEventListener('click', async (e) => { - if (e.target.innerText === '停止') { + if (e.target.innerText === this.BREAK) { this.isBreak = true } else { - this.inputValue = this.inputEl.value - this.inputEl.value = '' // 清空输入框 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) + } - private async queryAI(): Promise { - this.operateSendButtonEl('停止') - this.aiPreTextEl.innerText = '正在编写...' - this.inputEl.value = '' + // 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.innerText = this.BREAK + this.sendButtonEl.style.display = 'block' + this.aiPreTextEl.innerText = '按ESC退出 | 正在编写...' // 显示提示语 // 这里实现实际的AI查询逻辑 try { - console.log('this.inputValue', this.inputValue) - const ollama = new Ollama({ host: this.host }) - const res = await ollama.generate({ - model: this.model, - prompt: this.inputValue, - stream: true, + 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 = '' - for await (const chunk of res) { + + while (true) { if (this.isBreak) { this.isBreak = false break } - content += chunk.response - this.showAIResponse(content) + + 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.innerText = '' // 清空提示语 - this.operateSendButtonEl('发送', false) // 隐藏发送按钮 + // 隐藏发送按钮 + this.sendButtonEl.innerText = this.SEND + this.sendButtonEl.style.display = 'none' } + return content } catch (error) { console.error('AI查询失败:', error) @@ -164,83 +215,69 @@ export class AI { } private showAIResponse(response: string) { - if (!this.resultPopup || !this.inputContainer) return + if (!this.resultPopupEl) return // 显示结果 - this.resultPopup.innerHTML = response - this.resultPopup.style.display = 'block' + this.resultPopupEl.innerHTML = response + this.resultPopupEl.style.display = 'block' } private createActionMenu() { - if (this.actionMenu) return - - this.actionMenu = document.createElement('div') - this.actionMenu.className = 'ql-ai-actions' - this.actionMenu.style.display = 'none' // 初始隐藏 - - const actions = ['完成', '重新生成', '关闭'] - actions.forEach((action) => { - const menuItem = document.createElement('div') - menuItem.className = 'ql-ai-action-item' - menuItem.textContent = action - menuItem.addEventListener('click', () => this.handleAction(action)) - this.actionMenu?.appendChild(menuItem) - }) + 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.wrapContainer.appendChild(this.actionMenu) + this.wrapContainerEl.appendChild(this.actionMenuEl) + } // 展示下拉菜单 - this.actionMenu!.style.display = 'block' + this.actionMenuEl.style.display = 'block' } private handleAction(action: string) { switch (action) { - case '完成': + case this.DONE: this.insertAIResponse() break - case '重新生成': + case this.REGENERATE: this.regenerateResponse() break - case '关闭': + case this.CLOSE: this.closeAIPanel() break } } private insertAIResponse() { - if (!this.resultPopup) return + if (!this.resultPopupEl) return const range = this.quill.getSelection(true) if (range) { // 使用HTML方式插入可以保留格式 this.quill.clipboard.dangerouslyPasteHTML( range.index, - this.resultPopup.innerHTML, - ) - // 将光标移动到插入内容的末尾 - this.quill.setSelection( - range.index + this.resultPopup.innerHTML.length, - 0, - 'api', + this.resultPopupEl.innerHTML, ) } this.closeAIPanel() } private async regenerateResponse() { - this.wrapContainer.removeChild(this.actionMenu) - this.actionMenu = null - if (this.inputValue) { - this.inputEl.value = '' // 清空输入框 - await this.queryAI() - } + this.actionMenuEl.style.display = 'none' + await this.queryAI(this.inputValue) } private closeAIPanel() { - if (this.dialogContainer) { - this.quill.container.removeChild(this.dialogContainer) - this.dialogContainer = null - this.inputContainer = null - this.resultPopup = null - this.actionMenu = null - } + this.isBreak = true // 停止查询 + this.quill.container.removeChild(this.dialogContainerEl) + this.dialogContainerEl = null + this.actionMenuEl = null } } From 6b9d7b8b9638aa49453ce21639be947f4196b1ef Mon Sep 17 00:00:00 2001 From: chenxi <2465950588@qq.com> Date: Thu, 17 Apr 2025 01:04:48 -0700 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E8=A7=A3=E5=86=B3=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E7=BA=BF=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fluent-editor/src/modules/ai/index.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/fluent-editor/src/modules/ai/index.ts b/packages/fluent-editor/src/modules/ai/index.ts index cb8f5711..fa172fb2 100644 --- a/packages/fluent-editor/src/modules/ai/index.ts +++ b/packages/fluent-editor/src/modules/ai/index.ts @@ -98,8 +98,8 @@ export class AI { this.dialogContainerEl.appendChild(this.wrapContainerEl) } - this.aiPreTextEl.innerText = `${this.model}帮你写:` - this.sendButtonEl.innerText = this.SEND + 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) @@ -119,7 +119,7 @@ export class AI { } }) this.sendButtonEl.addEventListener('click', async (e) => { - if (e.target.innerText === this.BREAK) { + if (e.target.textContent === this.BREAK) { this.isBreak = true } else { @@ -148,9 +148,9 @@ export class AI { // 有信息 this.isBreak = false // 重置打断标记,防止重复打断ai - this.sendButtonEl.innerText = this.BREAK + this.sendButtonEl.textContent = this.BREAK this.sendButtonEl.style.display = 'block' - this.aiPreTextEl.innerText = '按ESC退出 | 正在编写...' // 显示提示语 + this.aiPreTextEl.textContent = '按ESC退出 | 正在编写...' // 显示提示语 // 这里实现实际的AI查询逻辑 try { const response = await fetch(`${this.host}/api/generate`, { @@ -201,9 +201,9 @@ export class AI { // 创建操作菜单 this.createActionMenu() if (content) { - this.aiPreTextEl.innerText = '' // 清空提示语 + this.aiPreTextEl.textContent = '' // 清空提示语 // 隐藏发送按钮 - this.sendButtonEl.innerText = this.SEND + this.sendButtonEl.textContent = this.SEND this.sendButtonEl.style.display = 'none' } return content @@ -276,7 +276,9 @@ export class AI { private closeAIPanel() { this.isBreak = true // 停止查询 - this.quill.container.removeChild(this.dialogContainerEl) + if(this.dialogContainerEl) { + this.quill.container.removeChild(this.dialogContainerEl) + } this.dialogContainerEl = null this.actionMenuEl = null } From 1ef81d12a1f586112455c9f643f37024535fb987 Mon Sep 17 00:00:00 2001 From: chenxi <2465950588@qq.com> Date: Sun, 20 Jul 2025 23:46:55 -0700 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0AI=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E7=9A=84api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/docs/fluent-editor/docs/demo/ai.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/docs/fluent-editor/docs/demo/ai.md b/packages/docs/fluent-editor/docs/demo/ai.md index 326e81c1..cd013c86 100644 --- a/packages/docs/fluent-editor/docs/demo/ai.md +++ b/packages/docs/fluent-editor/docs/demo/ai.md @@ -1,6 +1,21 @@ # AI -通过工具栏配置AI。 +通过模块配置AI。 :::demo src=demos/ai.vue ::: + +## API + +`ai` 模块配置项: + +```typescript +interface AIOption { + // AI大模型主机地址 + host: string + // 大模型名称 + model: string + // API 密钥 + apiKey: string +} +``` From d3e9cbb3cd4edd29b512106963da242edb919624 Mon Sep 17 00:00:00 2001 From: Kagol Date: Wed, 23 Jul 2025 17:56:48 +0800 Subject: [PATCH 5/5] chore: update ai-icon --- packages/fluent-editor/src/ui/icons.config.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/fluent-editor/src/ui/icons.config.ts b/packages/fluent-editor/src/ui/icons.config.ts index e87200af..e1b8dd9c 100644 --- a/packages/fluent-editor/src/ui/icons.config.ts +++ b/packages/fluent-editor/src/ui/icons.config.ts @@ -307,8 +307,17 @@ export const FORMAT_PAINTER_ICON = `` -export const AI_ICON = ` - - - -` +export const AI_ICON = ` + + + + + + + + + + + + +`