diff --git a/CHANGELOG.md b/CHANGELOG.md index c128f2f2628a06d5a2805356018628521ede232f..68efd5a592bd93b6b9f63be3d1f265487ffc5b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 新增markdown支持手动编辑模式 - 新增重复器表格样式2组件 - 新增代码编辑器支持AI行内聊天 +- 新增行内聊天组件 ## [0.7.41-alpha.37] - 2025-11-14 diff --git a/src/locale/en/index.ts b/src/locale/en/index.ts index 3760ab7a8634ff616386af5b87f99caa5f3319d5..18b1acf603a6577463fd2a750cd57bedc4cad940 100644 --- a/src/locale/en/index.ts +++ b/src/locale/en/index.ts @@ -892,6 +892,13 @@ export default { prev: 'Previous record', next: 'Next record', }, + inlineAiUtil: { + regenerate: 'Regenerate', + insertText: 'Insert Text', + replaceText: 'Replace Text', + copyText: 'Copy Text', + info: 'The content is generated by AI, please carefully discern.', + }, }, // runTime ...runTimeEn, diff --git a/src/locale/zh-CN/index.ts b/src/locale/zh-CN/index.ts index a3c71a2e4d301601f3b169b898e48259e8be5526..d1edc3f3af1415039957f578a5a8f5346c106e64 100644 --- a/src/locale/zh-CN/index.ts +++ b/src/locale/zh-CN/index.ts @@ -836,6 +836,13 @@ export default { prev: '上一个记录', next: '下一个记录', }, + inlineAiUtil: { + regenerate: '重新生成', + insertText: '插入文本', + replaceText: '替换文本', + copyText: '复制文本', + info: '内容由 AI 生成,请仔细甄别。' + }, }, // runTime ...runTimeZhCN, diff --git a/src/util/inline-ai-util/inline-ai-textarea/icon.tsx b/src/util/inline-ai-util/inline-ai-textarea/icon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..02d6b93d73ca0743a108f2171b70628ec7f954e8 --- /dev/null +++ b/src/util/inline-ai-util/inline-ai-textarea/icon.tsx @@ -0,0 +1,200 @@ +/** + * 终止图标 + */ +export const StopIcon = ( + + + +); + +/** + * AI图标 + */ +export const AIIcon = ( + + + + + +); + +/** + * 发送图标 + */ +export const SendIcon = ( + + + + + +); + +/** + * 重新生成图标 + */ +export const RegenerateIcon = ( + + + + + + + +); +/** + * 插入图标 + */ +export const insertTextIcon = ( + + + + + +); + +/** + * 替换图标 + */ +export const ReplaceTextIcon = ( + + + + + +); +/** + * 拷贝图标 + */ +export const CopyTextIcon = ( + + + + + + + +); +/** + * 取消图标 + */ +export const CancelIcon = ( + + + + + +); diff --git a/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.hook.ts b/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.hook.ts index 96e16e4fdf518205fff0e5f29fb107417ef5bf57..2ed0adb60bff54c545014d41e06b31be1c7f78eb 100644 --- a/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.hook.ts +++ b/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.hook.ts @@ -1,8 +1,14 @@ -import { computed, onMounted, onUnmounted } from 'vue'; - -/** - * @description 计算参数 - */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ref, Ref, computed, onMounted, onUnmounted, CSSProperties } from 'vue'; +import { IAppDEACMode, IAppDataEntity } from '@ibiz/model-core'; +import { calcResPath } from '@ibiz-template/runtime'; +import { + CancelIcon, + CopyTextIcon, + insertTextIcon, + RegenerateIcon, + ReplaceTextIcon, +} from './icon'; /** * @description 计算参数 @@ -59,7 +65,6 @@ export const computedInLineAIDynaStyle = ( */ export const useInLineAIContainerClick = (props: IData): void => { const handclick = (evt: MouseEvent): void => { - evt.stopPropagation(); const target = evt.target as HTMLElement; // 检查被点击元素或其父级元素中是否存在具有 ibiz-inline-ai-textarea-container 类名的元素 if (!target.closest('.ibiz-inline-ai-textarea-container')) { @@ -73,3 +78,221 @@ export const useInLineAIContainerClick = (props: IData): void => { document.removeEventListener('click', handclick, true); }); }; + +/** + * 使用AI功能 + * @param props + * @param srfmode + */ +export const useAI = ( + props: { + data: IData; + context: IContext; + deACMode: IAppDEACMode; + }, + opts: { + srfaiappendcurdata: boolean; + srfmode: string | undefined; + }, +): { + askAI: (content: string) => Promise; +} => { + const { context, data, deACMode } = props; + const { srfaiappendcurdata, srfmode } = opts; + const params = { srfactag: deACMode.codeName }; + + const app = ibiz.hub.getApp(deACMode.appId); + + // AI历史会话数据 + let history: IData[] = []; + + /** + * @description 计算AI路径 + * @param {IAppDataEntity} appDataEntity + * @param {boolean} [isHistories=false] + * @returns {*} {string} + */ + const calcAIPath = ( + appDataEntity: IAppDataEntity, + isHistories: boolean = false, + ): string => { + const srfkey = context[appDataEntity.codeName!.toLowerCase()]; + const curPath = `/${appDataEntity.deapicodeName2}/chatcompletion${ + isHistories ? '/histories' : '' + }${srfkey ? `/${srfkey}` : ''}`; + const resPath = calcResPath(context, appDataEntity); + return resPath ? `/${resPath}${curPath}` : `${curPath}`; + }; + + /** + * @description 加载AI历史数据 + * @returns {*} {Promise} + */ + const loadAiHistory = async (): Promise => { + const appDataEntity = await ibiz.hub.getAppDataEntity( + deACMode.appDataEntityId!, + deACMode.appId, + ); + const path = calcAIPath(appDataEntity, true); + const body = {}; + if (srfaiappendcurdata) Object.assign(body, { data }); + if (srfmode) Object.assign(body, { mode: srfmode }); + const response = await app.net.post(path, body, params); + if (response.ok && Array.isArray(response.data)) { + history = response.data.filter(item => + ['USER', 'ASSISTANT'].includes(item.role), + ); + } + }; + + /** + * @description 询问AI + * @param {string} content + * @returns {*} {(Promise)} + */ + const askAI = async (content: string): Promise => { + // 准备请求体数据 + const body = { + messages: [ + ...history, + { + role: 'USER', + content, + }, + ], + }; + if (srfmode) Object.assign(body, { mode: srfmode }); + const appDataEntity = await ibiz.hub.getAppDataEntity( + deACMode.appDataEntityId!, + deACMode.appId, + ); + const path = calcAIPath(appDataEntity); + const response = await app.net.post(path, body, params); + if (response.ok) { + const result = response.data.choices?.[0]?.content; + return result; + } + return ''; + }; + + onMounted(() => { + loadAiHistory(); + }); + + return { + askAI, + }; +}; + +export interface IActionItem { + /** + * @description 图标 + * @type {*} + * @memberof IActionItem + */ + icon?: any; + /** + * @description 标题 + * @type {string} + * @memberof IActionItem + */ + title?: string; + /** + * @description 行为名称 + * @type {string} + * @memberof IActionItem + */ + actionName?: string; + /** + * @description 项类型 + * @type {('action' | 'divider')}(行为项 | 分隔项) + * @memberof IActionItem + */ + itemType: 'action' | 'divider'; +} + +/** + * 使用行为 + * @returns + */ +export const useActions = ( + container: Ref, + target: Ref, +): { + actions: IActionItem[]; + actionStyle: Ref; +} => { + const actions: IActionItem[] = [ + { + title: ibiz.i18n.t('util.inlineAiUtil.regenerate'), + icon: RegenerateIcon, + itemType: 'action', + actionName: 'regenerate', + }, + { + title: ibiz.i18n.t('util.inlineAiUtil.insertText'), + icon: insertTextIcon, + itemType: 'action', + actionName: 'insertText', + }, + { + title: ibiz.i18n.t('util.inlineAiUtil.replaceText'), + icon: ReplaceTextIcon, + itemType: 'action', + actionName: 'replaceText', + }, + { + itemType: 'divider', + }, + { + title: ibiz.i18n.t('util.inlineAiUtil.copyText'), + icon: CopyTextIcon, + itemType: 'action', + actionName: 'copyText', + }, + { + title: ibiz.i18n.t('app.cancel'), + icon: CancelIcon, + itemType: 'action', + actionName: 'cancel', + }, + ]; + + const actionStyle = ref({}); + + /** + * @description 计算行为窗样式 + */ + const calcActionStyle = (isInit: boolean = false) => { + if (!container.value || !target.value) return; + const containerRect = container.value.getBoundingClientRect(); + const targetHeight = target.value.offsetHeight; + const spaceBelow = window.innerHeight - containerRect.bottom; + // 计算位置 + const position = + (isInit ? containerRect.height + 41 : containerRect.height) + 4; + + if (spaceBelow >= targetHeight + 8) { + // 下方空间足够,显示在下方 + actionStyle.value.top = `${position}px`; + actionStyle.value.bottom = 'auto'; + } else { + // 下方空间不足,显示在上方 + actionStyle.value.bottom = `${position}px`; + actionStyle.value.top = 'auto'; + } + }; + + const onResize = () => calcActionStyle(); + + onMounted(() => { + calcActionStyle(true); + window.addEventListener('resize', onResize); + }); + + onUnmounted(() => { + window.removeEventListener('resize', onResize); + }); + + return { actions, actionStyle }; +}; diff --git a/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.scss b/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.scss index d4006305b36bd15489d80b6525d80b3696668798..e13df9738a6dd56831c7f2ac6a981b61d0f8e170 100644 --- a/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.scss +++ b/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.scss @@ -1,11 +1,110 @@ @include b(inline-ai-container-context-menu) { - color: red; + color: red; } @include b(inline-ai-textarea-container) { + position: absolute; + z-index: 99999999; + user-select: none; + background-color: getCssVar(color, bg-1); + border: 1px solid getCssVar(color, border); + border-radius: getCssVar(border-radius, small); + box-shadow: rgb(0 0 0 / 8%) 0 0 16px 0; + @include e(content) { + position: relative; + display: flex; + gap: getCssVar(spacing, tight); + padding: getCssVar(spacing, base, tight); + @include m(prefix) { + flex-shrink: 0; + } + @include m(ai-icon) { + color: getCssVar(color, disabled, text); + } + @include m(textarea) { + flex-grow: 1; + padding: 0; + resize: none; + border: none; + outline: none; + + &:disabled { + background-color: getCssVar(color, bg-1); + } + } + @include m(suffix) { + display: flex; + flex-direction: column-reverse; + flex-shrink: 0; + } + @include m(sand-icon) { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + color: getCssVar(color, primary); + cursor: pointer; + border-radius: getCssVar(spacing, extra, tight); + + &:hover { + background-color: getCssVar(color, primary, light, default); + } + } + @include m(stop-icon) { + display: flex; + gap: getCssVar(spacing, extra, tight); + align-items: center; + font-size: getCssVar(font-size, small); + color: getCssVar(color, disabled, text); + cursor: pointer; + + &:hover { + color: getCssVar(color, primary, hover); + } + } + } + + @include e(footer) { + padding: getCssVar(spacing, base, tight) getCssVar(spacing, base); + font-size: getCssVar(font-size, small); + color: getCssVar(color, disabled, text); + background-color: getCssVar(color, disabled, fill); + border-top: 1px solid getCssVar(color, disabled, border); + } + + @include e(actions) { position: absolute; - z-index: 99999999; - padding: 16px; - background: #fff; + width: 240px; + padding: getCssVar(spacing, base, tight) 0; + font-size: getCssVar(font-size, regular); + visibility: hidden; + background-color: getCssVar(color, bg-1); + border: 1px solid getCssVar(color, border); + border-radius: getCssVar(border-radius, small); box-shadow: rgb(0 0 0 / 8%) 0 0 16px 0; -} \ No newline at end of file + @include when(show) { + visibility: visible; + } + @include m(action) { + display: flex; + gap: getCssVar(spacing, tight); + align-items: center; + padding: getCssVar(spacing, tight) getCssVar(spacing, base, loose); + cursor: pointer; + + &:hover { + background-color: getCssVar(color, fill-0); + } + @include when(danger) { + &:hover { + color: getCssVar(color, danger); + } + } + } + @include m(divider) { + margin: getCssVar(spacing, extra, tight) getCssVar(spacing, base, loose); + border-top: 1px solid getCssVar(color, border); + } + } +} diff --git a/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.tsx b/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.tsx index fcd5b3312396e7fb37c3936ed1a4b82d7f12af98..1722a6edb4a246ff39be298517e601a4328d544d 100644 --- a/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.tsx +++ b/src/util/inline-ai-util/inline-ai-textarea/inline-ai-textarea.tsx @@ -1,15 +1,18 @@ /* eslint-disable no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { defineComponent, PropType } from 'vue'; +import { defineComponent, PropType, ref } from 'vue'; import { IAppDEACMode } from '@ibiz/model-core'; import { useNamespace } from '@ibiz-template/vue3-util'; import { IInLineAiChatOptions } from '@ibiz-template/runtime'; -import './inline-ai-textarea.scss'; import { - computedInLineAIDynaStyle, + useAI, + useActions, computedInLineAIParams, + computedInLineAIDynaStyle, useInLineAIContainerClick, } from './inline-ai-textarea.hook'; +import { AIIcon, SendIcon, StopIcon } from './icon'; +import './inline-ai-textarea.scss'; export const InlineAITextArea = defineComponent({ props: { @@ -57,6 +60,10 @@ export const InlineAITextArea = defineComponent({ setup(props, ctx) { const ns = useNamespace('inline-ai-textarea-container'); + const containerRef = ref(); + + const actionsRef = ref(); + // 预置参数 const { srfaiappendcurdata, srfaiautoappend, srfmode } = computedInLineAIParams(props); @@ -68,20 +75,179 @@ export const InlineAITextArea = defineComponent({ // 处理点击事件 useInLineAIContainerClick(props); + const { askAI } = useAI(props, { + srfaiappendcurdata, + srfmode, + }); + + const { actions, actionStyle } = useActions(containerRef, actionsRef); + + /** + * 多行文本框内容 + */ + const textareaContent = ref(props.content); + /** + * 问题 + */ + let question: string; + /** + * 内容类型(用户|助手) + */ + const contentType = ref<'USER' | 'ASSISTANT'>('USER'); + /** + * 是否在加载状态 + */ + const isLoading = ref(false); + /** + * @description 发送问题 + * @returns {*} {Promise} + */ + const sendQuestion = async (content: string): Promise => { + // 保存问题 + question = content; + textareaContent.value = ''; // 清空内容 + isLoading.value = true; + textareaContent.value = await askAI(question); + isLoading.value = false; + contentType.value = 'ASSISTANT'; + }; + + /** + * @description 终止编辑并关闭AI聊天 + */ + const stopQuestion = (): void => { + props.unMountAIChat(); + }; + + /** + * @description 回车事件 + * @param {KeyboardEvent} e + */ + const onKeydown = (e: KeyboardEvent) => { + if (e.code === 'Enter' && !e.isComposing) { + e.stopPropagation(); + if (e.shiftKey === false) { + sendQuestion(textareaContent.value); + } + } + }; + + /** + * @description 处理行为 + * @param {MouseEvent} e + * @param {string} actionName + */ + const handleAction = (_e: MouseEvent, actionName: string) => { + const content = textareaContent.value; + switch (actionName) { + case 'regenerate': + sendQuestion(question); + break; + case 'insertText': + props.insertText(content); + props.unMountAIChat(); + break; + case 'replaceText': + props.replaceSelectionText(content); + break; + case 'copyText': + ibiz.util.text.copy(content); + props.unMountAIChat(); + case 'cancel': + props.unMountAIChat(); + break; + default: + break; + } + }; + return { ns, - containerDynaStyle, + actions, + isLoading, + actionsRef, + actionStyle, + contentType, + containerRef, + textareaContent, contentDynaStyle, + containerDynaStyle, + onKeydown, + sendQuestion, + stopQuestion, + handleAction, }; }, render() { return ( -
+
- 编辑区 + {this.contentType === 'USER' && ( +
+
{AIIcon}
+
+ )} +