feat: 支持用户输入变量

--story=1016155 --user=刘瑞斌 【应用编排】-支持设置用户输入变量 https://www.tapd.cn/57709429/s/1576480
This commit is contained in:
CaptainB 2024-09-09 19:10:16 +08:00 committed by 刘瑞斌
parent ba023d20f0
commit d48b51c3e0
9 changed files with 438 additions and 45 deletions

View File

@ -166,10 +166,10 @@ class Flow:
class WorkflowManage:
def __init__(self, flow: Flow, params, work_flow_post_handler: WorkFlowPostHandler,
base_to_response: BaseToResponse = SystemToResponse()):
base_to_response: BaseToResponse = SystemToResponse(), form_data = {}):
self.params = params
self.flow = flow
self.context = {}
self.context = form_data
self.node_context = []
self.work_flow_post_handler = work_flow_post_handler
self.current_node = None

View File

@ -694,6 +694,7 @@ class ApplicationSerializer(serializers.Serializer):
'tts_model_id': application.tts_model_id,
'stt_model_enable': application.stt_model_enable,
'tts_model_enable': application.tts_model_enable,
'work_flow': application.work_flow,
'show_source': application_access_token.show_source})
@transaction.atomic
@ -855,10 +856,15 @@ class ApplicationSerializer(serializers.Serializer):
nodes = instance.get('work_flow')['nodes']
for node in nodes:
if node['id'] == 'base-node':
instance['stt_model_id'] = node['properties']['node_data']['stt_model_id']
instance['tts_model_id'] = node['properties']['node_data']['tts_model_id']
instance['stt_model_enable'] = node['properties']['node_data']['stt_model_enable']
instance['tts_model_enable'] = node['properties']['node_data']['tts_model_enable']
node_data = node['properties']['node_data']
if 'stt_model_id' in node_data:
instance['stt_model_id'] = node_data['stt_model_id']
if 'tts_model_id' in node_data:
instance['tts_model_id'] = node_data['tts_model_id']
if 'stt_model_enable' in node_data:
instance['stt_model_enable'] = node_data['stt_model_enable']
if 'tts_model_enable' in node_data:
instance['tts_model_enable'] = node_data['tts_model_enable']
break
def speech_to_text(self, file, with_valid=True):

View File

@ -208,6 +208,7 @@ class ChatMessageSerializer(serializers.Serializer):
application_id = serializers.UUIDField(required=False, allow_null=True, error_messages=ErrMessage.uuid("应用id"))
client_id = serializers.CharField(required=True, error_messages=ErrMessage.char("客户端id"))
client_type = serializers.CharField(required=True, error_messages=ErrMessage.char("客户端类型"))
form_data = serializers.DictField(required=False, error_messages=ErrMessage.char("全局变量"))
def is_valid_application_workflow(self, *, raise_exception=False):
self.is_valid_intraday_access_num()
@ -284,6 +285,7 @@ class ChatMessageSerializer(serializers.Serializer):
stream = self.data.get('stream')
client_id = self.data.get('client_id')
client_type = self.data.get('client_type')
form_data = self.data.get('form_data')
user_id = chat_info.application.user_id
work_flow_manage = WorkflowManage(Flow.new_instance(chat_info.work_flow_version.work_flow),
{'history_chat_record': chat_info.chat_record_list, 'question': message,
@ -291,7 +293,7 @@ class ChatMessageSerializer(serializers.Serializer):
'stream': stream,
're_chat': re_chat,
'user_id': user_id}, WorkFlowPostHandler(chat_info, client_id, client_type),
base_to_response)
base_to_response, form_data)
r = work_flow_manage.run()
return r

View File

@ -126,6 +126,7 @@ class ChatView(APIView):
'application_id': (request.auth.keywords.get(
'application_id') if request.auth.client_type == AuthenticationType.APPLICATION_ACCESS_TOKEN.value else None),
'client_id': request.auth.client_id,
'form_data': (request.data.get('form_data') if 'form_data' in request.data else []),
'client_type': request.auth.client_type}).chat()
@action(methods=['GET'], detail=False)

View File

@ -17,7 +17,9 @@
class="problem-button ellipsis-2 mb-8"
:class="log ? 'disabled' : 'cursor'"
>
<el-icon><EditPen /></el-icon>
<el-icon>
<EditPen />
</el-icon>
{{ item.str }}
</div>
<MdPreview
@ -33,6 +35,24 @@
</el-card>
</div>
</div>
<div v-if="inputFieldList.length > 0">
<div class="avatar">
<img v-if="data.avatar" :src="data.avatar" height="30px" />
<LogoIcon v-else height="30px" />
</div>
<div class="content">
<el-card shadow="always" class="dialog-card">
<DynamicsForm
v-model="form_data"
:model="form_data"
label-position="left"
require-asterisk-position="right"
:render_data="inputFieldList"
ref="dynamicsFormRef"
/>
</el-card>
</div>
</div>
<template v-for="(item, index) in chatList" :key="index">
<!-- 问题 -->
<div class="item-content mb-16 lighter">
@ -98,10 +118,12 @@
v-if="item.is_stop && !item.write_ed"
@click="startChat(item)"
link
>继续</el-button
>继续
</el-button
>
<el-button type="primary" v-else-if="!item.write_ed" @click="stopChat(item)" link
>停止回答</el-button
>停止回答
</el-button
>
</div>
</div>
@ -116,7 +138,9 @@
</div>
<div style="float: right;" v-if="props.data.tts_model_enable">
<el-button :disabled="!item.write_ed" @click="playAnswerText(item.answer_text)">
<el-icon><VideoPlay /></el-icon>
<el-icon>
<VideoPlay />
</el-icon>
</el-button>
</div>
</div>
@ -141,13 +165,17 @@
v-if="mediaRecorderStatus"
@click="startRecording"
>
<el-icon><Microphone /></el-icon>
<el-icon>
<Microphone />
</el-icon>
</el-button>
<el-button
v-else
@click="stopRecording"
>
<el-icon><VideoPause /></el-icon>
<el-icon>
<VideoPause />
</el-icon>
</el-button>
</div>
<div class="operate">
@ -189,6 +217,9 @@ import { debounce } from 'lodash'
import Recorder from 'recorder-core'
import 'recorder-core/src/engine/mp3'
import 'recorder-core/src/engine/mp3-engine'
import { MsgWarning } from '@/utils/message'
import DynamicsForm from '@/components/dynamics-form/index.vue'
import type { FormField } from '@/components/dynamics-form/type'
defineOptions({ name: 'AiChat' })
const route = useRoute()
@ -199,7 +230,8 @@ const {
const props = defineProps({
data: {
type: Object,
default: () => {}
default: () => {
}
},
appId: String, //
log: Boolean,
@ -234,6 +266,8 @@ const loading = ref(false)
const inputValue = ref('')
const chartOpenId = ref('')
const chatList = ref<any[]>([])
const inputFieldList = ref<FormField[]>([])
const form_data = ref<any>({})
const isDisabledChart = computed(
() => !(inputValue.value.trim() && (props.appId || props.data?.name))
@ -248,15 +282,15 @@ const prologueList = computed(() => {
.reduce((pre_array: Array<any>, current: string, index: number) => {
const currentObj = isMdArray(current)
? {
type: 'question',
str: current.replace(/^-\s+/, ''),
index: index
}
type: 'question',
str: current.replace(/^-\s+/, ''),
index: index
}
: {
type: 'md',
str: current,
index: index
}
type: 'md',
str: current,
index: index
}
if (pre_array.length > 0) {
const pre = pre_array[pre_array.length - 1]
if (!isMdArray(current) && pre.type == 'md') {
@ -286,10 +320,48 @@ watch(
{ deep: true }
)
function handleInputFieldList() {
props.data.work_flow?.nodes
.filter((v: any) => v.id === 'base-node')
.map((v: any) => {
inputFieldList.value = v.properties.input_field_list.map((v: any) => {
switch (v.type) {
case 'input':
return { field: v.variable, input_type: 'TextInput', label: v.name, required: v.is_required }
case 'select':
return {
field: v.variable,
input_type: 'SingleSelect',
label: v.name,
required: v.is_required,
option_list: v.optionList.map((o: any) => {
return { key: o, value: o }
})
}
case 'date':
return {
field: v.variable,
input_type: 'DatePicker',
label: v.name,
required: v.is_required,
attrs: {
'format': 'YYYY-MM-DD HH:mm:ss',
'value-format': 'YYYY-MM-DD HH:mm:ss',
'type': 'datetime'
}
}
default:
break
}
})
})
}
watch(
() => props.data,
() => {
chartOpenId.value = ''
handleInputFieldList()
},
{ deep: true }
)
@ -327,6 +399,13 @@ const handleDebounceClick = debounce((val) => {
}, 200)
function sendChatHandle(event: any) {
// inputFieldList
for (let i = 0; i < inputFieldList.value.length; i++) {
if (inputFieldList.value[i].required && !form_data.value[inputFieldList.value[i].field]) {
MsgWarning('请填写所有必填字段')
return
}
}
if (!event.ctrlKey) {
// ctrl
event.preventDefault()
@ -340,12 +419,14 @@ function sendChatHandle(event: any) {
inputValue.value += '\n'
}
}
const stopChat = (chat: chatType) => {
ChatManagement.stop(chat.id)
}
const startChat = (chat: chatType) => {
ChatManagement.write(chat.id)
}
/**
* 对话
*/
@ -398,6 +479,7 @@ function getChartOpenId(chat?: any) {
}
}
}
/**
* 获取一个递归函数,处理流式数据
* @param chat 每一条对话记录
@ -483,6 +565,7 @@ const errorWrite = (chat: any, message?: string) => {
ChatManagement.updateStatus(chat.id, 500)
ChatManagement.close(chat.id)
}
function chatMessage(chat?: any, problem?: string, re_chat?: boolean) {
loading.value = true
if (!chat) {
@ -513,7 +596,8 @@ function chatMessage(chat?: any, problem?: string, re_chat?: boolean) {
} else {
const obj = {
message: chat.problem_text,
re_chat: re_chat || false
re_chat: re_chat || false,
form_data: form_data.value
}
//
applicationApi
@ -618,8 +702,8 @@ const handleScroll = () => {
}
//
const mediaRecorder= ref<any>(null)
const audioPlayer= ref<HTMLAudioElement | null>(null)
const mediaRecorder = ref<any>(null)
const audioPlayer = ref<HTMLAudioElement | null>(null)
const mediaRecorderStatus = ref(true)
@ -630,11 +714,11 @@ const startRecording = async () => {
mediaRecorder.value = new Recorder({
type: 'mp3',
bitRate: 128,
sampleRate: 44100,
sampleRate: 44100
})
mediaRecorder.value.open(() => {
mediaRecorder.value.start()
mediaRecorder.value.start()
}, (err: any) => {
console.error(err)
})
@ -648,13 +732,13 @@ const stopRecording = () => {
if (mediaRecorder.value) {
mediaRecorderStatus.value = true
mediaRecorder.value.stop((blob: Blob, duration: number) => {
// blob
// const link = document.createElement('a')
// link.href = window.URL.createObjectURL(blob)
// link.download = 'abc.mp3'
// link.click()
// blob
// const link = document.createElement('a')
// link.href = window.URL.createObjectURL(blob)
// link.download = 'abc.mp3'
// link.click()
uploadRecording(blob) //
uploadRecording(blob) //
}, (err: any) => {
console.error('录音失败:', err)
})
@ -666,12 +750,12 @@ const uploadRecording = async (audioBlob: Blob) => {
try {
const formData = new FormData()
formData.append('file', audioBlob, 'recording.mp3')
applicationApi.postSpeechToText(props.data.id as string, formData, loading)
.then((response) => {
console.log('上传成功:', response.data)
inputValue.value = response.data
// chatMessage(null, res.data)
})
applicationApi.postSpeechToText(props.data.id as string, formData, loading)
.then((response) => {
console.log('上传成功:', response.data)
inputValue.value = response.data
// chatMessage(null, res.data)
})
} catch (error) {
console.error('上传失败:', error)
@ -697,10 +781,10 @@ const playAnswerText = (text: string) => {
// audioPlayer DOM
if (audioPlayer.value instanceof HTMLAudioElement) {
audioPlayer.value.src = url;
audioPlayer.value.play(); //
audioPlayer.value.src = url
audioPlayer.value.play() //
} else {
console.error("audioPlayer.value is not an instance of HTMLAudioElement");
console.error('audioPlayer.value is not an instance of HTMLAudioElement')
}
})
.catch((err) => {
@ -708,6 +792,10 @@ const playAnswerText = (text: string) => {
})
}
onMounted(() => {
handleInputFieldList()
})
function setScrollBottom() {
//
scrollDiv.value.setScrollTop(getMaxHeight())
@ -751,15 +839,19 @@ defineExpose({
.avatar {
float: left;
}
.content {
padding-left: var(--padding-left);
:deep(ol) {
margin-left: 16px !important;
}
}
.text {
padding: 6px 0;
}
.problem-button {
width: 100%;
border: none;
@ -772,25 +864,30 @@ defineExpose({
color: var(--el-text-color-regular);
-webkit-line-clamp: 1;
word-break: break-all;
&:hover {
background: var(--el-color-primary-light-9);
}
&.disabled {
&:hover {
background: var(--app-layout-bg-color);
}
}
:deep(.el-icon) {
color: var(--el-color-primary);
}
}
}
&__operate {
background: #f3f7f9;
position: relative;
width: 100%;
box-sizing: border-box;
z-index: 10;
&:before {
background: linear-gradient(0deg, #f3f7f9 0%, rgba(243, 247, 249, 0) 100%);
content: '';
@ -800,6 +897,7 @@ defineExpose({
left: 0;
height: 16px;
}
.operate-textarea {
box-shadow: 0px 6px 24px 0px rgba(31, 35, 41, 0.08);
background-color: #ffffff;
@ -818,16 +916,21 @@ defineExpose({
padding: 12px 16px;
box-sizing: border-box;
}
.operate {
padding: 6px 10px;
.sent-button {
max-height: none;
.el-icon {
font-size: 24px;
}
}
:deep(.el-loading-spinner) {
margin-top: -15px;
.circular {
width: 31px;
height: 31px;
@ -836,11 +939,13 @@ defineExpose({
}
}
}
.dialog-card {
border: none;
border-radius: 8px;
}
}
.chat-width {
max-width: var(--app-chat-width, 860px);
margin: 0 auto;

View File

@ -0,0 +1,5 @@
<template>
<el-date-picker v-bind="$attrs" />
</template>
<script setup lang="ts"></script>
<style lang="scss"></style>

View File

@ -0,0 +1,159 @@
<template>
<el-dialog
:title="isEdit ? '编辑变量' : '添加变量'"
v-model="dialogVisible"
:close-on-click-modal="false"
:close-on-press-escape="false"
:destroy-on-close="true"
append-to-body
>
<el-form
label-position="top"
ref="fieldFormRef"
:rules="rules"
:model="form"
require-asterisk-position="right"
>
<el-form-item label="变量名" prop="name">
<el-input
v-model="form.name"
placeholder="请输入变量名"
maxlength="64"
show-word-limit
@blur="form.name = form.name.trim()"
/>
</el-form-item>
<el-form-item label="变量" prop="variable">
<el-input
v-model="form.variable"
placeholder="请输入变量"
maxlength="64"
show-word-limit
@blur="form.variable = form.variable.trim()"
/>
</el-form-item>
<el-form-item label="输入类型">
<el-select v-model="form.type">
<el-option label="文本框" value="input"/>
<el-option label="日期" value="date"/>
<el-option label="下拉选项" value="select"/>
</el-select>
</el-form-item>
<el-form-item v-if="form.type === 'select'">
<template #label>
<div class="flex-between">
选项值
<el-button link type="primary" @click="addOption()">
<el-icon class="mr-4"><Plus /></el-icon>
</el-button>
</div>
</template>
<template #default>
<div class="w-full flex-between" :key="option" v-for="(option, $index) in form.optionList">
<input class="el-textarea__inner" v-model.lazy="form.optionList[$index]" placeholder="请输入选项值"/>
<el-button link type="primary" @click="delOption($index)">
<el-icon class="mr-4"><Remove /></el-icon>
</el-button>
</div>
</template>
</el-form-item>
<el-form-item label="是否必填" @click.prevent>
<el-switch size="small" v-model="form.is_required"></el-switch>
</el-form-item>
<el-form-item label="赋值方式">
<el-radio-group v-model="form.assignment_method">
<el-radio label="user_input">用户输入</el-radio>
<el-radio label="api_input">接口传参</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click.prevent="dialogVisible = false"> 取消 </el-button>
<el-button type="primary" @click="submit(fieldFormRef)" :loading="loading">
{{ isEdit ? '保存' : '添加' }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import type { FormInstance } from 'element-plus'
import { cloneDeep, debounce } from 'lodash'
import { MsgError } from '@/utils/message'
const emit = defineEmits(['refresh'])
const fieldFormRef = ref()
const loading = ref<boolean>(false)
const isEdit = ref(false)
const form = ref<any>({
name: '',
variable: '',
type: 'input',
is_required: true,
assignment_method: 'user_input',
optionList: []
})
const rules = reactive({
name: [{ required: true, message: '请输入变量名', trigger: 'blur' }],
variable: [{ required: true, message: '请输入变量', trigger: 'blur' }]
})
const dialogVisible = ref<boolean>(false)
watch(dialogVisible, (bool) => {
if (!bool) {
form.value = {
name: '',
variable: '',
type: 'input',
is_required: true,
assignment_method: 'user_input',
optionList: []
}
isEdit.value = false
}
})
const open = (row: any) => {
if (row) {
form.value = cloneDeep(row)
isEdit.value = true
}
dialogVisible.value = true
}
const close = () => {
dialogVisible.value = false
}
const submit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
if (form.value.type === 'select' && form.value.optionList.length === 0) {
return MsgError('请添加选项值')
}
await formEl.validate((valid) => {
if (valid) {
emit('refresh', form.value)
}
})
}
const addOption = () => {
form.value.optionList.push('')
}
const delOption = (index: number) => {
form.value.optionList.splice(index, 1)
}
defineExpose({ open, close })
</script>
<style lang="scss" scoped></style>

View File

@ -196,6 +196,53 @@
</el-select>
</el-form-item>
</el-form>
<div class="flex-between">
全局变量
<el-button link type="primary" @click="openAddDialog()">
<el-icon class="mr-4"><Plus /></el-icon>
</el-button>
</div>
<el-table :data="props.nodeModel.properties.input_field_list" class="mb-16">
<el-table-column prop="name" label="变量名" />
<el-table-column prop="variable" label="变量" />
<el-table-column label="输入类型">
<template #default="{ row }">
<el-tag type="info" class="info-tag" v-if="row.type === 'input'">文本框</el-tag>
<el-tag type="info" class="info-tag" v-if="row.type === 'date'">日期</el-tag>
<el-tag type="info" class="info-tag" v-if="row.type === 'select'">下拉选项</el-tag>
</template>
</el-table-column>
<el-table-column label="必填">
<template #default="{ row }">
<div @click.stop>
<el-switch size="small" v-model="row.is_required" />
</div>
</template>
</el-table-column>
<el-table-column prop="source" label="赋值方式">
<template #default="{ row }">
{{ row.source === 'user_input' ? '用户输入' : '接口传参' }}
</template>
</el-table-column>
<el-table-column label="操作" align="left" width="80">
<template #default="{ row, $index }">
<span class="mr-4">
<el-tooltip effect="dark" content="修改" placement="top">
<el-button type="primary" text @click.stop="openAddDialog(row, $index)">
<el-icon><EditPen /></el-icon>
</el-button>
</el-tooltip>
</span>
<el-tooltip effect="dark" content="删除" placement="top">
<el-button type="primary" text @click="deleteField($index)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<!-- 回复内容弹出层 -->
<el-dialog v-model="dialogVisible" title="开场白" append-to-body>
<MdEditor v-model="cloneContent" :preview="false" :toolbars="[]" :footers="[]"></MdEditor>
@ -206,6 +253,7 @@
</template>
</el-dialog>
</NodeContainer>
<FieldFormDialog ref="FieldFormDialogRef" @refresh="refreshFieldList" />
</template>
<script setup lang="ts">
import { app } from '@/main'
@ -218,6 +266,8 @@ import { relatedObject } from '@/utils/utils'
import useStore from '@/stores'
import applicationApi from '@/api/application'
import type { Provider } from '@/api/type/model'
import FieldFormDialog from './component/FieldFormDialog.vue'
import { MsgError } from '@/utils/message'
const { model } = useStore()
const {
@ -304,9 +354,47 @@ function getTTSModel() {
})
}
const currentIndex = ref(null)
const FieldFormDialogRef = ref()
const inputFieldList = ref<any[]>([])
function openAddDialog(data?: any, index?: any) {
if (typeof index !== 'undefined') {
currentIndex.value = index
}
FieldFormDialogRef.value.open(data)
}
function deleteField(index: any) {
inputFieldList.value.splice(index, 1)
}
function refreshFieldList(data: any) {
for (let i = 0; i < inputFieldList.value.length; i++) {
if (inputFieldList.value[i].variable === data.variable && currentIndex.value !== i) {
MsgError('变量已存在: ' + data.variable)
return
}
}
if (currentIndex.value !== null) {
inputFieldList.value.splice(currentIndex.value, 1, data)
} else {
inputFieldList.value.push(data)
}
currentIndex.value = null
FieldFormDialogRef.value.close()
}
onMounted(() => {
set(props.nodeModel, 'validate', validate)
if (props.nodeModel.properties.input_field_list) {
props.nodeModel.properties.input_field_list.forEach((item: any) => {
inputFieldList.value.push(item)
})
}
set(props.nodeModel.properties, 'input_field_list', inputFieldList)
getProvider()
getTTSModel()
getSTTModel()

View File

@ -13,6 +13,18 @@
</el-button>
</el-tooltip>
</div>
<div v-for="(item, index) in inputFieldList" :key="index"
class="flex-between border-r-4 p-8-12 mb-8 layout-bg lighter"
@mouseenter="showicon = true"
@mouseleave="showicon = false"
>
<span>{{ item.name }} {{ '{' + item.variable + '}' }}</span>
<el-tooltip effect="dark" content="复制参数" placement="top" v-if="showicon === true">
<el-button link @click="copyClick('{{' + '全局变量.' + item.variable + '}}')" style="padding: 0">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
</el-tooltip>
</div>
</NodeContainer>
</template>
<script setup lang="ts">
@ -27,8 +39,23 @@ const globeLabel = '{{全局变量.time}}'
const showicon = ref(false)
// onMounted(() => {
// set(props.nodeModel, 'validate', validate)
// })
const inputFieldList = ref<any[]>([])
onMounted(() => {
props.nodeModel.graphModel.nodes
.filter((v: any) => v.id === 'base-node')
.map((v: any) => {
// eslint-disable-next-line vue/no-mutating-props
props.nodeModel.properties.config.globalFields = [
{
label: '当前时间',
value: 'time'
}, ...v.properties.input_field_list.map((i: any) => {
return { label: i.name, value: i.variable }
})
]
inputFieldList.value = v.properties.input_field_list
})
})
</script>
<style lang="scss" scoped></style>