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 = `
+`