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,