337 lines
8.6 KiB
Vue
337 lines
8.6 KiB
Vue
<template>
|
||
<div :style="containerStyle" @submit.prevent class="rounded-lg border shadow-sm bg-card text-card-foreground">
|
||
<aurora-editor
|
||
v-model="localModelValue"
|
||
:extensions="mergedExtensions"
|
||
:hideToolbar="hideToolbar"
|
||
:hideMenubar="hideMenubar"
|
||
:disabled="disabled"
|
||
:maxHeight="maxHeight"
|
||
:minHeight="minHeight"
|
||
:output="output"
|
||
:placeholder="placeholder"
|
||
>
|
||
</aurora-editor>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts" name="aiEditorComponent">
|
||
import { computed, toRefs } from 'vue';
|
||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||
import request from '/@/utils/request';
|
||
import { Session } from '/@/utils/storage';
|
||
import {
|
||
Bold,
|
||
BulletList,
|
||
Italic,
|
||
BaseKit,
|
||
Underline,
|
||
Strike,
|
||
LineHeight,
|
||
Image,
|
||
History,
|
||
Heading,
|
||
CodeBlock,
|
||
FontSize,
|
||
Highlight,
|
||
Table,
|
||
Clear,
|
||
Blockquote,
|
||
Link,
|
||
Color,
|
||
Video,
|
||
OrderedList,
|
||
HorizontalRule,
|
||
Fullscreen,
|
||
TaskList,
|
||
MoreMark,
|
||
FormatPainter,
|
||
SlashCommand,
|
||
Indent,
|
||
locale,
|
||
ImportWord,
|
||
Columns,
|
||
TextAlign,
|
||
ImageUpload,
|
||
VideoUpload,
|
||
FontFamily,
|
||
FindAndReplace,
|
||
Code,
|
||
AI,
|
||
Preview,
|
||
Printer,
|
||
AuroraEditor,
|
||
} from 'aurora-editor';
|
||
import 'aurora-editor/style.css';
|
||
import other from '/@/utils/other';
|
||
import { useThemeConfig } from '/@/stores/themeConfig';
|
||
import { storeToRefs } from 'pinia';
|
||
|
||
// 获取主题配置
|
||
const storesThemeConfig = useThemeConfig();
|
||
const { themeConfig } = storeToRefs(storesThemeConfig);
|
||
|
||
// 获取布局配置信息
|
||
const getThemeConfig = computed(() => {
|
||
return themeConfig.value;
|
||
});
|
||
|
||
// 设置编辑器语言为简体中文
|
||
locale.setLang('zhHans');
|
||
|
||
// 定义组件 Props
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
modelValue?: string; // 编辑器内容,用于 v-model
|
||
width?: string | number; // 编辑器容器宽度
|
||
maxHeight?: number; // 编辑器最大高度
|
||
minHeight?: number; // 编辑器最小高度
|
||
hideToolbar?: boolean; // 是否隐藏工具栏
|
||
hideMenubar?: boolean; // 是否隐藏菜单栏
|
||
disabled?: boolean; // 是否禁用编辑器
|
||
customExtensions?: any[]; // 自定义扩展
|
||
placeholder?: string; // 编辑器占位符文本
|
||
characterLimit?: number; // 编辑器字符数限制
|
||
enableImage?: boolean; // 是否启用图片功能
|
||
enableVideo?: boolean; // 是否启用视频功能
|
||
output?: string; // 是否启用输出功能
|
||
}>(),
|
||
{
|
||
modelValue: '',
|
||
width: '100%', // 默认宽度
|
||
maxHeight: 800, // 默认最大高度
|
||
minHeight: 800, // 默认最小高度
|
||
hideToolbar: false, // 默认不隐藏工具栏
|
||
hideMenubar: true, // 默认不隐藏菜单栏
|
||
disabled: false, // 默认不禁用
|
||
customExtensions: () => [], // 默认无自定义扩展
|
||
placeholder: '', // 默认无占位符
|
||
characterLimit: 50000, // 默认字符限制
|
||
enableImage: false, // 默认禁用图片功能
|
||
enableVideo: false, // 默认禁用视频功能
|
||
output: 'html', // 默认输出 HTML
|
||
}
|
||
);
|
||
|
||
// 提取props中的属性以直接在模板中使用,避免使用props.xxx语法
|
||
const { hideToolbar, hideMenubar, disabled, maxHeight, minHeight, output } = toRefs(props);
|
||
|
||
// 使用计算属性创建一个双向绑定的本地变量,避免直接修改props
|
||
const localModelValue = computed({
|
||
get: () => props.modelValue,
|
||
set: (value) => emit('update:modelValue', value),
|
||
});
|
||
|
||
// 定义emit
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: string];
|
||
}>();
|
||
|
||
// 计算编辑器容器样式
|
||
const containerStyle = computed(() => ({
|
||
width: typeof props.width === 'number' ? `${props.width}px` : props.width, // 处理数字和字符串类型的宽度
|
||
}));
|
||
|
||
// 定义默认的编辑器扩展
|
||
const defaultExtensions = computed(() => {
|
||
// Base configurations reused in both modes
|
||
const baseKitConfig = {
|
||
placeholder: {
|
||
showOnlyCurrent: true,
|
||
placeholder: props.placeholder,
|
||
},
|
||
characterCount: {
|
||
limit: props.characterLimit,
|
||
},
|
||
};
|
||
const aiConfig = {
|
||
completions: AICompletions,
|
||
shortcuts: [
|
||
{
|
||
label: '自定义选项',
|
||
children: [
|
||
{
|
||
label: '重写文案',
|
||
prompt:
|
||
'Rewrite this content with no spelling mistakes, proper grammar, and with more descriptive language, using best writing practices without losing the original meaning.',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
// Core extensions used in both modes (except Fullscreen which is conditional)
|
||
const coreExtensions = [BaseKit.configure(baseKitConfig), AI.configure(aiConfig)];
|
||
// Base list for the full editor experience
|
||
let fullExtensions: any[] = [
|
||
...coreExtensions,
|
||
History,
|
||
Columns,
|
||
FormatPainter,
|
||
Clear,
|
||
Heading.configure({ spacer: true }),
|
||
FontSize,
|
||
FontFamily,
|
||
Bold,
|
||
Italic,
|
||
Underline,
|
||
Strike,
|
||
MoreMark,
|
||
Color.configure({ spacer: true }),
|
||
Highlight,
|
||
BulletList,
|
||
OrderedList,
|
||
TextAlign.configure({ types: ['heading', 'paragraph', 'image'], spacer: true }),
|
||
Indent,
|
||
LineHeight,
|
||
TaskList.configure({
|
||
spacer: true,
|
||
taskItem: {
|
||
nested: true,
|
||
},
|
||
}),
|
||
Link,
|
||
Blockquote,
|
||
SlashCommand,
|
||
HorizontalRule,
|
||
CodeBlock,
|
||
Table,
|
||
Code,
|
||
ImportWord.configure({
|
||
upload: handleFileUpload,
|
||
}),
|
||
FindAndReplace.configure({ spacer: true }),
|
||
Printer.configure({ pageTitle: getThemeConfig.value.globalTitle }),
|
||
Preview,
|
||
Fullscreen, // Fullscreen is also part of the full set
|
||
];
|
||
|
||
// Conditionally add image extensions
|
||
if (props.enableImage) {
|
||
fullExtensions.push(
|
||
Image, // 图片
|
||
ImageUpload.configure({
|
||
// 图片上传配置
|
||
upload: (file: File) => {
|
||
return new Promise<string>((resolve) => {
|
||
setTimeout(() => {
|
||
const fileUrl = URL.createObjectURL(file);
|
||
resolve(fileUrl);
|
||
}, 1000);
|
||
});
|
||
},
|
||
})
|
||
);
|
||
}
|
||
|
||
// Conditionally add video extensions
|
||
if (props.enableVideo) {
|
||
fullExtensions.push(
|
||
Video, // 视频
|
||
VideoUpload.configure({
|
||
// 视频上传配置
|
||
upload: handleFileUpload,
|
||
})
|
||
);
|
||
}
|
||
|
||
return fullExtensions;
|
||
});
|
||
|
||
// 合并默认扩展和自定义扩展
|
||
const mergedExtensions = computed(() => {
|
||
return [...defaultExtensions.value, ...props.customExtensions];
|
||
});
|
||
|
||
// 统一文件上传处理 (视频、Word导入等)
|
||
async function handleFileUpload(files: File[]) {
|
||
// 生成预览 URL
|
||
const uploads = files.map((file) => ({
|
||
src: URL.createObjectURL(file),
|
||
alt: file.name,
|
||
}));
|
||
// 模拟上传延迟
|
||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||
// 返回上传结果 (实际应返回服务器 URL)
|
||
return Promise.resolve(uploads);
|
||
}
|
||
|
||
// AI 补全处理函数 (使用 Server-Sent Events)
|
||
async function AICompletions(history: Array<{ role: string; content: string }> = [], signal?: AbortSignal): Promise<ReadableStream> {
|
||
try {
|
||
// 构建后端 API 地址
|
||
const backendApiUrl = `${request.defaults.baseURL}${other.adaptationUrl('/knowledge/completions/text')}`;
|
||
// 获取认证信息
|
||
const token = Session.getToken();
|
||
const tenantId = Session.getTenant();
|
||
|
||
// 准备发送到后端的消息 (假设后端处理系统提示)
|
||
const messagesToSend = history;
|
||
|
||
// 创建 ReadableStream 用于流式传输
|
||
let streamController: ReadableStreamDefaultController<any>;
|
||
const stream = new ReadableStream({
|
||
start(controller) {
|
||
streamController = controller; // 保存控制器以推送数据
|
||
},
|
||
cancel() {
|
||
// 处理流取消的逻辑 (例如,清理资源)
|
||
},
|
||
});
|
||
|
||
// 使用 fetchEventSource 发起 SSE 请求
|
||
fetchEventSource(backendApiUrl, {
|
||
method: 'POST', // 请求方法
|
||
headers: {
|
||
// 请求头
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${token}`, // 认证 Token
|
||
'TENANT-ID': tenantId, // 租户 ID
|
||
},
|
||
body: JSON.stringify(messagesToSend), // 请求体 (发送的消息)
|
||
signal, // 用于中止请求的 AbortSignal
|
||
openWhenHidden: true, // 保持连接即使页面不可见
|
||
onmessage(event) {
|
||
const { message } = JSON.parse(event.data);
|
||
// 接收到消息时的回调
|
||
// 检查是否是结束标记
|
||
if (message === '[DONE]') {
|
||
streamController.close(); // 关闭流
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 获取消息内容
|
||
if (message) {
|
||
streamController.enqueue({
|
||
choices: [{ delta: { content: message } }], // 模拟 OpenAI 流格式
|
||
});
|
||
}
|
||
} catch (error) {
|
||
// 移除 console.error 以修复 linter 错误
|
||
streamController.error(error); // 将错误推送到流中
|
||
}
|
||
},
|
||
onclose() {
|
||
// 连接关闭时的回调
|
||
streamController.close(); // 关闭流
|
||
},
|
||
onerror(error) {
|
||
// 发生错误时的回调
|
||
streamController.error(error); // 将错误推送到流中
|
||
},
|
||
});
|
||
|
||
// 返回创建的 ReadableStream
|
||
return stream;
|
||
} catch (error) {
|
||
// 创建一个包含错误信息的流
|
||
return new ReadableStream({
|
||
start(controller) {
|
||
controller.error(error);
|
||
},
|
||
});
|
||
}
|
||
}
|
||
</script>
|