feat: 工作流表单节点

This commit is contained in:
shaohuzhang1 2024-11-13 10:37:16 +08:00 committed by shaohuzhang1
parent 4e615db713
commit 1a1e93296e
53 changed files with 2032 additions and 1016 deletions

View File

@ -7,6 +7,7 @@
@desc:
"""
import time
import uuid
from abc import abstractmethod
from typing import Type, Dict, List
@ -31,7 +32,7 @@ def write_context(step_variable: Dict, global_variable: Dict, node, workflow):
if workflow.is_result(node, NodeResult(step_variable, global_variable)) and 'answer' in step_variable:
answer = step_variable['answer']
yield answer
workflow.answer += answer
workflow.append_answer(answer)
if global_variable is not None:
for key in global_variable:
workflow.context[key] = global_variable[key]
@ -54,15 +55,27 @@ class WorkFlowPostHandler:
'message_tokens' in row and row.get('message_tokens') is not None])
answer_tokens = sum([row.get('answer_tokens') for row in details.values() if
'answer_tokens' in row and row.get('answer_tokens') is not None])
chat_record = ChatRecord(id=chat_record_id,
chat_id=chat_id,
problem_text=question,
answer_text=answer,
details=details,
message_tokens=message_tokens,
answer_tokens=answer_tokens,
run_time=time.time() - workflow.context['start_time'],
index=0)
answer_text_list = workflow.get_answer_text_list()
answer_text = '\n\n'.join(answer_text_list)
if workflow.chat_record is not None:
chat_record = workflow.chat_record
chat_record.answer_text = answer_text
chat_record.details = details
chat_record.message_tokens = message_tokens
chat_record.answer_tokens = answer_tokens
chat_record.answer_text_list = answer_text_list
chat_record.run_time = time.time() - workflow.context['start_time']
else:
chat_record = ChatRecord(id=chat_record_id,
chat_id=chat_id,
problem_text=question,
answer_text=answer_text,
details=details,
message_tokens=message_tokens,
answer_tokens=answer_tokens,
answer_text_list=answer_text_list,
run_time=time.time() - workflow.context['start_time'],
index=0)
self.chat_info.append_chat_record(chat_record, self.client_id)
# 重新设置缓存
chat_cache.set(chat_id,
@ -118,7 +131,15 @@ class FlowParamsSerializer(serializers.Serializer):
class INode:
def __init__(self, node, workflow_params, workflow_manage):
@abstractmethod
def save_context(self, details, workflow_manage):
pass
def get_answer_text(self):
return self.answer_text
def __init__(self, node, workflow_params, workflow_manage, runtime_node_id=None):
# 当前步骤上下文,用于存储当前步骤信息
self.status = 200
self.err_message = ''
@ -129,7 +150,12 @@ class INode:
self.node_params_serializer = None
self.flow_params_serializer = None
self.context = {}
self.answer_text = None
self.id = node.id
if runtime_node_id is None:
self.runtime_node_id = str(uuid.uuid1())
else:
self.runtime_node_id = runtime_node_id
def valid_args(self, node_params, flow_params):
flow_params_serializer_class = self.get_flow_params_serializer_class()

View File

@ -9,19 +9,23 @@
from .ai_chat_step_node import *
from .application_node import BaseApplicationNode
from .condition_node import *
from .question_node import *
from .search_dataset_node import *
from .start_node import *
from .direct_reply_node import *
from .form_node import *
from .function_lib_node import *
from .function_node import *
from .question_node import *
from .reranker_node import *
from .document_extract_node import *
from .image_understand_step_node import *
from .search_dataset_node import *
from .start_node import *
node_list = [BaseStartStepNode, BaseChatNode, BaseSearchDatasetNode, BaseQuestionNode, BaseConditionNode, BaseReplyNode,
BaseFunctionNodeNode, BaseFunctionLibNodeNode, BaseRerankerNode, BaseApplicationNode, BaseDocumentExtractNode,
BaseImageUnderstandNode]
BaseFunctionNodeNode, BaseFunctionLibNodeNode, BaseRerankerNode, BaseApplicationNode,
BaseDocumentExtractNode,
BaseImageUnderstandNode, BaseFormNode]
def get_node(node_type):

View File

@ -32,7 +32,7 @@ def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, wo
node.context['question'] = node_variable['question']
node.context['run_time'] = time.time() - node.context['start_time']
if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
workflow.answer += answer
node.answer_text = answer
def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
@ -73,6 +73,11 @@ def get_default_model_params_setting(model_id):
class BaseChatNode(IChatNode):
def save_context(self, details, workflow_manage):
self.context['answer'] = details.get('answer')
self.context['question'] = details.get('question')
self.answer_text = details.get('answer')
def execute(self, model_id, system, prompt, dialogue_number, history_chat_record, stream, chat_id, chat_record_id,
model_params_setting=None,
**kwargs) -> NodeResult:

View File

@ -21,7 +21,7 @@ def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, wo
node.context['question'] = node_variable['question']
node.context['run_time'] = time.time() - node.context['start_time']
if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
workflow.answer += answer
node.answer_text = answer
def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
@ -64,6 +64,12 @@ def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, wor
class BaseApplicationNode(IApplicationNode):
def save_context(self, details, workflow_manage):
self.context['answer'] = details.get('answer')
self.context['question'] = details.get('question')
self.context['type'] = details.get('type')
self.answer_text = details.get('answer')
def execute(self, application_id, message, chat_id, chat_record_id, stream, re_chat, client_id, client_type,
**kwargs) -> NodeResult:
from application.serializers.chat_message_serializers import ChatMessageSerializer

View File

@ -14,6 +14,10 @@ from application.flow.step_node.condition_node.i_condition_node import IConditio
class BaseConditionNode(IConditionNode):
def save_context(self, details, workflow_manage):
self.context['branch_id'] = details.get('branch_id')
self.context['branch_name'] = details.get('branch_name')
def execute(self, **kwargs) -> NodeResult:
branch_list = self.node_params_serializer.data['branch']
branch = self._execute(branch_list)

View File

@ -13,6 +13,9 @@ from application.flow.step_node.direct_reply_node.i_reply_node import IReplyNode
class BaseReplyNode(IReplyNode):
def save_context(self, details, workflow_manage):
self.context['answer'] = details.get('answer')
self.answer_text = details.get('answer')
def execute(self, reply_type, stream, fields=None, content=None, **kwargs) -> NodeResult:
if reply_type == 'referencing':
result = self.get_reference_content(fields)

View File

@ -0,0 +1,9 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file __init__.py.py
@date2024/11/4 14:48
@desc:
"""
from .impl import *

View File

@ -0,0 +1,32 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file i_form_node.py
@date2024/11/4 14:48
@desc:
"""
from typing import Type
from rest_framework import serializers
from application.flow.i_step_node import INode, NodeResult
from common.util.field_message import ErrMessage
class FormNodeParamsSerializer(serializers.Serializer):
form_field_list = serializers.ListField(required=True, error_messages=ErrMessage.list("表单配置"))
form_content_format = serializers.CharField(required=True, error_messages=ErrMessage.char('表单输出内容'))
class IFormNode(INode):
type = 'form-node'
def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
return FormNodeParamsSerializer
def _run(self):
return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
def execute(self, form_field_list, form_content_format, **kwargs) -> NodeResult:
pass

View File

@ -0,0 +1,9 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file __init__.py.py
@date2024/11/4 14:49
@desc:
"""
from .base_form_node import BaseFormNode

View File

@ -0,0 +1,84 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file base_form_node.py
@date2024/11/4 14:52
@desc:
"""
import json
import time
from typing import Dict
from langchain_core.prompts import PromptTemplate
from application.flow.i_step_node import NodeResult
from application.flow.step_node.form_node.i_form_node import IFormNode
def write_context(step_variable: Dict, global_variable: Dict, node, workflow):
if step_variable is not None:
for key in step_variable:
node.context[key] = step_variable[key]
if workflow.is_result(node, NodeResult(step_variable, global_variable)) and 'result' in step_variable:
result = step_variable['result']
yield result
node.answer_text = result
node.context['run_time'] = time.time() - node.context['start_time']
class BaseFormNode(IFormNode):
def save_context(self, details, workflow_manage):
self.context['result'] = details.get('result')
self.context['form_content_format'] = details.get('form_content_format')
self.context['form_field_list'] = details.get('form_field_list')
self.context['run_time'] = details.get('run_time')
self.context['start_time'] = details.get('start_time')
self.answer_text = details.get('result')
def execute(self, form_field_list, form_content_format, **kwargs) -> NodeResult:
form_setting = {"form_field_list": form_field_list, "runtime_node_id": self.runtime_node_id,
"chat_record_id": self.flow_params_serializer.data.get("chat_record_id"),
"is_submit": self.context.get("is_submit", False)}
form = f'<form_rander>{json.dumps(form_setting)}</form_rander>'
prompt_template = PromptTemplate.from_template(form_content_format, template_format='jinja2')
value = prompt_template.format(form=form)
return NodeResult(
{'result': value, 'form_field_list': form_field_list, 'form_content_format': form_content_format}, {},
_write_context=write_context)
def get_answer_text(self):
form_content_format = self.context.get('form_content_format')
form_field_list = self.context.get('form_field_list')
form_setting = {"form_field_list": form_field_list, "runtime_node_id": self.runtime_node_id,
"chat_record_id": self.flow_params_serializer.data.get("chat_record_id"),
'form_data': self.context.get('form_data', {}),
"is_submit": self.context.get("is_submit", False)}
form = f'<form_rander>{json.dumps(form_setting)}</form_rander>'
prompt_template = PromptTemplate.from_template(form_content_format, template_format='jinja2')
value = prompt_template.format(form=form)
return value
def get_details(self, index: int, **kwargs):
form_content_format = self.context.get('form_content_format')
form_field_list = self.context.get('form_field_list')
form_setting = {"form_field_list": form_field_list, "runtime_node_id": self.runtime_node_id,
"chat_record_id": self.flow_params_serializer.data.get("chat_record_id"),
'form_data': self.context.get('form_data', {}),
"is_submit": self.context.get("is_submit", False)}
form = f'<form_rander>{json.dumps(form_setting)}</form_rander>'
prompt_template = PromptTemplate.from_template(form_content_format, template_format='jinja2')
value = prompt_template.format(form=form)
return {
'name': self.node.properties.get('stepName'),
"index": index,
"result": value,
"form_content_format": self.context.get('form_content_format'),
"form_field_list": self.context.get('form_field_list'),
'form_data': self.context.get('form_data'),
'start_time': self.context.get('start_time'),
'run_time': self.context.get('run_time'),
'type': self.node.type,
'status': self.status,
'err_message': self.err_message
}

View File

@ -91,6 +91,9 @@ def convert_value(name: str, value, _type, is_required, source, node):
class BaseFunctionLibNodeNode(IFunctionLibNode):
def save_context(self, details, workflow_manage):
self.context['result'] = details.get('result')
self.answer_text = details.get('result')
def execute(self, function_lib_id, input_field_list, **kwargs) -> NodeResult:
function_lib = QuerySet(FunctionLib).filter(id=function_lib_id).first()
if not function_lib.is_active:

View File

@ -78,6 +78,10 @@ def convert_value(name: str, value, _type, is_required, source, node):
class BaseFunctionNodeNode(IFunctionNode):
def save_context(self, details, workflow_manage):
self.context['result'] = details.get('result')
self.answer_text = details.get('result')
def execute(self, input_field_list, code, **kwargs) -> NodeResult:
params = {field.get('name'): convert_value(field.get('name'), field.get('value'), field.get('type'),
field.get('is_required'), field.get('source'), self)

View File

@ -58,6 +58,11 @@ def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, wor
class BaseImageUnderstandNode(IImageUnderstandNode):
def save_context(self, details, workflow_manage):
self.context['answer'] = details.get('answer')
self.context['question'] = details.get('question')
self.answer_text = details.get('answer')
def execute(self, model_id, system, prompt, dialogue_number, history_chat_record, stream, chat_id, chat_record_id,
image,
**kwargs) -> NodeResult:

View File

@ -32,7 +32,7 @@ def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, wo
node.context['question'] = node_variable['question']
node.context['run_time'] = time.time() - node.context['start_time']
if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
workflow.answer += answer
node.answer_text = answer
def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
@ -73,6 +73,14 @@ def get_default_model_params_setting(model_id):
class BaseQuestionNode(IQuestionNode):
def save_context(self, details, workflow_manage):
self.context['run_time'] = details.get('run_time')
self.context['question'] = details.get('question')
self.context['answer'] = details.get('answer')
self.context['message_tokens'] = details.get('message_tokens')
self.context['answer_tokens'] = details.get('answer_tokens')
self.answer_text = details.get('answer')
def execute(self, model_id, system, prompt, dialogue_number, history_chat_record, stream, chat_id, chat_record_id,
model_params_setting=None,
**kwargs) -> NodeResult:

View File

@ -44,6 +44,13 @@ def filter_result(document_list: List[Document], max_paragraph_char_number, top_
class BaseRerankerNode(IRerankerNode):
def save_context(self, details, workflow_manage):
self.context['document_list'] = details.get('document_list', [])
self.context['question'] = details.get('question')
self.context['run_time'] = details.get('run_time')
self.context['result_list'] = details.get('result_list')
self.context['result'] = details.get('result')
def execute(self, question, reranker_setting, reranker_list, reranker_model_id,
**kwargs) -> NodeResult:
documents = merge_reranker_list(reranker_list)

View File

@ -45,6 +45,21 @@ def reset_title(title):
class BaseSearchDatasetNode(ISearchDatasetStepNode):
def save_context(self, details, workflow_manage):
result = details.get('paragraph_list', [])
dataset_setting = self.node_params_serializer.data.get('dataset_setting')
directly_return = '\n'.join(
[f"{paragraph.get('title', '')}:{paragraph.get('content')}" for paragraph in result if
paragraph.get('is_hit_handling_method')])
self.context['paragraph_list'] = result
self.context['question'] = details.get('question')
self.context['run_time'] = details.get('run_time')
self.context['is_hit_handling_method_list'] = [row for row in result if row.get('is_hit_handling_method')]
self.context['data'] = '\n'.join(
[f"{paragraph.get('title', '')}:{paragraph.get('content')}" for paragraph in
result])[0:dataset_setting.get('max_paragraph_char_number', 5000)]
self.context['directly_return'] = directly_return
def execute(self, dataset_id_list, dataset_setting, question,
exclude_paragraph_id_list=None,
**kwargs) -> NodeResult:

View File

@ -6,9 +6,6 @@
@date2024/6/3 16:54
@desc:
"""
from typing import Type
from rest_framework import serializers
from application.flow.i_step_node import INode, NodeResult

View File

@ -31,6 +31,17 @@ def get_global_variable(node):
class BaseStartStepNode(IStarNode):
def save_context(self, details, workflow_manage):
base_node = self.workflow_manage.get_base_node()
default_global_variable = get_default_global_variable(base_node.properties.get('input_field_list', []))
workflow_variable = {**default_global_variable, **get_global_variable(self)}
self.context['question'] = details.get('question')
self.context['run_time'] = details.get('run_time')
self.status = details.get('status')
self.err_message = details.get('err_message')
for key, value in workflow_variable.items():
workflow_manage.context[key] = value
def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
pass

View File

@ -9,7 +9,6 @@
import json
import threading
import traceback
import uuid
from concurrent.futures import ThreadPoolExecutor
from functools import reduce
from typing import List, Dict
@ -212,12 +211,12 @@ class NodeChunkManage:
except IndexError as e:
if self.current_node_chunk.is_end():
self.current_node_chunk = None
if len(self.work_flow.answer) > 0:
if self.work_flow.answer_is_not_empty():
chunk = self.work_flow.base_to_response.to_stream_chunk_response(
self.work_flow.params['chat_id'],
self.work_flow.params['chat_record_id'],
'\n\n', False, 0, 0)
self.work_flow.answer += '\n\n'
self.work_flow.append_answer('\n\n')
return chunk
return self.pop()
return None
@ -240,29 +239,65 @@ class NodeChunk:
class WorkflowManage:
def __init__(self, flow: Flow, params, work_flow_post_handler: WorkFlowPostHandler,
base_to_response: BaseToResponse = SystemToResponse(), form_data=None, image_list=None):
base_to_response: BaseToResponse = SystemToResponse(), form_data=None, image_list=None,
start_node_id=None,
start_node_data=None, chat_record=None):
if form_data is None:
form_data = {}
if image_list is None:
image_list = []
self.start_node = None
self.start_node_result_future = None
self.form_data = form_data
self.image_list = image_list
self.params = params
self.flow = flow
self.lock = threading.Lock()
self.context = {}
self.node_context = []
self.node_chunk_manage = NodeChunkManage(self)
self.work_flow_post_handler = work_flow_post_handler
self.current_node = None
self.current_result = None
self.answer = ""
self.answer_list = ['']
self.status = 0
self.base_to_response = base_to_response
self.chat_record = chat_record
if start_node_id is not None:
self.load_node(chat_record, start_node_id, start_node_data)
else:
self.node_context = []
def append_answer(self, content):
self.answer += content
self.answer_list[-1] += content
def answer_is_not_empty(self):
return len(self.answer_list[-1]) > 0
def load_node(self, chat_record, start_node_id, start_node_data):
self.node_context = []
self.answer = chat_record.answer_text
self.answer_list = chat_record.answer_text_list
self.answer_list.append('')
for node_details in sorted(chat_record.details.values(), key=lambda d: d.get('index')):
node_id = node_details.get('node_id')
if node_details.get('runtime_node_id') == start_node_id:
self.start_node = self.get_node_cls_by_id(node_id, node_details.get('runtime_node_id'))
self.start_node.valid_args(self.start_node.node_params, self.start_node.workflow_params)
self.start_node.save_context(node_details, self)
node_result = NodeResult({**start_node_data, 'form_data': start_node_data, 'is_submit': True}, {})
self.start_node_result_future = NodeResultFuture(node_result, None)
return
node_id = node_details.get('node_id')
node = self.get_node_cls_by_id(node_id, node_details.get('runtime_node_id'))
node.valid_args(node.node_params, node.workflow_params)
node.save_context(node_details, self)
self.node_context.append(node)
def run(self):
if self.params.get('stream'):
return self.run_stream()
return self.run_stream(self.start_node, self.start_node_result_future)
return self.run_block()
def run_block(self):
@ -270,7 +305,7 @@ class WorkflowManage:
非流式响应
@return: 结果
"""
result = self.run_chain_async(None)
result = self.run_chain_async(None, None)
result.result()
details = self.get_runtime_details()
message_tokens = sum([row.get('message_tokens') for row in details.values() if
@ -285,12 +320,12 @@ class WorkflowManage:
, message_tokens, answer_tokens,
_status=status.HTTP_200_OK if self.status == 200 else status.HTTP_500_INTERNAL_SERVER_ERROR)
def run_stream(self):
def run_stream(self, current_node, node_result_future):
"""
流式响应
@return:
"""
result = self.run_chain_async(None)
result = self.run_chain_async(current_node, node_result_future)
return tools.to_stream_response_simple(self.await_result(result))
def await_result(self, result):
@ -307,21 +342,23 @@ class WorkflowManage:
if chunk is None:
break
yield chunk
yield self.get_chunk_content('', True)
finally:
self.work_flow_post_handler.handler(self.params['chat_id'], self.params['chat_record_id'],
self.answer,
self)
yield self.get_chunk_content('', True)
def run_chain_async(self, current_node):
future = executor.submit(self.run_chain, current_node)
def run_chain_async(self, current_node, node_result_future):
future = executor.submit(self.run_chain, current_node, node_result_future)
return future
def run_chain(self, current_node):
def run_chain(self, current_node, node_result_future=None):
if current_node is None:
start_node = self.get_start_node()
current_node = get_node(start_node.type)(start_node, self.params, self)
node_result_future = self.run_node_future(current_node)
if node_result_future is None:
node_result_future = self.run_node_future(current_node)
try:
is_stream = self.params.get('stream', True)
# 处理节点响应
@ -335,7 +372,7 @@ class WorkflowManage:
# 获取到可执行的子节点
result_list = []
for node in node_list:
result = self.run_chain_async(node)
result = self.run_chain_async(node, None)
result_list.append(result)
[r.result() for r in result_list]
if self.status == 0:
@ -445,10 +482,41 @@ class WorkflowManage:
details_result = {}
for index in range(len(self.node_context)):
node = self.node_context[index]
if self.chat_record is not None and self.chat_record.details is not None:
details = self.chat_record.details.get(node.runtime_node_id)
if details is not None and self.start_node.runtime_node_id != node.runtime_node_id:
details_result[node.runtime_node_id] = details
continue
details = node.get_details(index)
details_result[str(uuid.uuid1())] = details
details['node_id'] = node.id
details['runtime_node_id'] = node.runtime_node_id
details_result[node.runtime_node_id] = details
return details_result
def get_answer_text_list(self):
answer_text_list = []
for index in range(len(self.node_context)):
node = self.node_context[index]
answer_text = node.get_answer_text()
if answer_text is not None:
if self.chat_record is not None and self.chat_record.details is not None:
details = self.chat_record.details.get(node.runtime_node_id)
if details is not None and self.start_node.runtime_node_id != node.runtime_node_id:
continue
answer_text_list.append(
{'content': answer_text, 'type': 'form' if node.type == 'form-node' else 'md'})
result = []
for index in range(len(answer_text_list)):
answer = answer_text_list[index]
if index == 0:
result.append(answer.get('content'))
continue
if answer.get('type') != answer_text_list[index - 1]:
result.append(answer.get('content'))
else:
result[-1] += answer.get('content')
return result
def get_next_node(self):
"""
获取下一个可运行的所有节点
@ -485,6 +553,8 @@ class WorkflowManage:
@param current_node_result: 当前可执行节点结果
@return: 可执行节点列表
"""
if current_node.type == 'form-node' and 'form_data' not in current_node_result.node_variable:
return []
node_list = []
if current_node_result is not None and current_node_result.is_assertion_result():
for edge in self.flow.edges:
@ -537,7 +607,6 @@ class WorkflowManage:
prompt = prompt.replace(globeLabel, globeValue)
context[node.id] = node.context
prompt_template = PromptTemplate.from_template(prompt, template_format='jinja2')
value = prompt_template.format(context=context)
return value
@ -557,11 +626,11 @@ class WorkflowManage:
base_node_list = [node for node in self.flow.nodes if node.type == 'base-node']
return base_node_list[0]
def get_node_cls_by_id(self, node_id):
def get_node_cls_by_id(self, node_id, runtime_node_id=None):
for node in self.flow.nodes:
if node.id == node_id:
node_instance = get_node(node.type)(node,
self.params, self)
self.params, self, runtime_node_id)
return node_instance
return None

View File

@ -1,10 +1,15 @@
# Generated by Django 4.2.15 on 2024-11-07 11:22
# Generated by Django 4.2.15 on 2024-11-13 10:13
import django.contrib.postgres.fields
from django.db import migrations, models
sql = """
UPDATE "public".application_chat_record
SET "answer_text_list" = ARRAY[answer_text];
"""
class Migration(migrations.Migration):
dependencies = [
('application', '0018_workflowversion_name'),
]
@ -20,4 +25,11 @@ class Migration(migrations.Migration):
name='file_upload_setting',
field=models.JSONField(default={}, verbose_name='文件上传相关设置'),
),
migrations.AddField(
model_name='chatrecord',
name='answer_text_list',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=40960), default=list,
size=None, verbose_name='改进标注列表'),
),
migrations.RunSQL(sql)
]

View File

@ -147,6 +147,9 @@ class ChatRecord(AppModelMixin):
default=VoteChoices.UN_VOTE)
problem_text = models.CharField(max_length=10240, verbose_name="问题")
answer_text = models.CharField(max_length=40960, verbose_name="答案")
answer_text_list = ArrayField(verbose_name="改进标注列表",
base_field=models.CharField(max_length=40960)
, default=list)
message_tokens = models.IntegerField(verbose_name="请求token数量", default=0)
answer_tokens = models.IntegerField(verbose_name="响应token数量", default=0)
const = models.IntegerField(verbose_name="总费用", default=0)

View File

@ -214,10 +214,15 @@ class OpenAIChatSerializer(serializers.Serializer):
class ChatMessageSerializer(serializers.Serializer):
chat_id = serializers.UUIDField(required=True, error_messages=ErrMessage.char("对话id"))
chat_id = serializers.UUIDField(required=True, error_messages=ErrMessage.uuid("对话id"))
message = serializers.CharField(required=True, error_messages=ErrMessage.char("用户问题"))
stream = serializers.BooleanField(required=True, error_messages=ErrMessage.char("是否流式回答"))
re_chat = serializers.BooleanField(required=True, error_messages=ErrMessage.char("是否重新回答"))
chat_record_id = serializers.UUIDField(required=False, allow_null=True,
error_messages=ErrMessage.uuid("对话记录id"))
runtime_node_id = serializers.CharField(required=False, allow_null=True, allow_blank=True,
error_messages=ErrMessage.char("节点id"))
node_data = serializers.DictField(required=False, error_messages=ErrMessage.char("节点参数"))
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("客户端类型"))
@ -293,6 +298,19 @@ class ChatMessageSerializer(serializers.Serializer):
pipeline_message.run(params)
return pipeline_message.context['chat_result']
@staticmethod
def get_chat_record(chat_info, chat_record_id):
if chat_info is not None:
chat_record_list = [chat_record for chat_record in chat_info.chat_record_list if
str(chat_record.id) == str(chat_record_id)]
if chat_record_list is not None and len(chat_record_list):
return chat_record_list[-1]
chat_record = QuerySet(ChatRecord).filter(id=chat_record_id, chat_id=chat_info.chat_id).first()
if chat_record is None:
raise ChatException(500, "对话纪要不存在")
chat_record = QuerySet(ChatRecord).filter(id=chat_record_id).first()
return chat_record
def chat_work_flow(self, chat_info: ChatInfo, base_to_response):
message = self.data.get('message')
re_chat = self.data.get('re_chat')
@ -302,15 +320,21 @@ class ChatMessageSerializer(serializers.Serializer):
form_data = self.data.get('form_data')
image_list = self.data.get('image_list')
user_id = chat_info.application.user_id
chat_record_id = self.data.get('chat_record_id')
chat_record = None
if chat_record_id is not None:
chat_record = self.get_chat_record(chat_info, chat_record_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,
'chat_id': chat_info.chat_id, 'chat_record_id': str(uuid.uuid1()),
'chat_id': chat_info.chat_id, 'chat_record_id': str(
uuid.uuid1()) if chat_record is None else chat_record.id,
'stream': stream,
're_chat': re_chat,
'client_id': client_id,
'client_type': client_type,
'user_id': user_id}, WorkFlowPostHandler(chat_info, client_id, client_type),
base_to_response, form_data, image_list)
base_to_response, form_data, image_list, self.data.get('runtime_node_id'),
self.data.get('node_data'), chat_record)
r = work_flow_manage.run()
return r

View File

@ -397,7 +397,7 @@ class ChatRecordSerializerModel(serializers.ModelSerializer):
class Meta:
model = ChatRecord
fields = ['id', 'chat_id', 'vote_status', 'problem_text', 'answer_text',
'message_tokens', 'answer_tokens', 'const', 'improve_paragraph_id_list', 'run_time', 'index',
'message_tokens', 'answer_tokens', 'const', 'improve_paragraph_id_list', 'run_time', 'index','answer_text_list',
'create_time', 'update_time']

View File

@ -129,9 +129,14 @@ class ChatView(APIView):
'client_id': request.auth.client_id,
'form_data': (request.data.get(
'form_data') if 'form_data' in request.data else {}),
'image_list': request.data.get(
'image_list') if 'image_list' in request.data else [],
'client_type': request.auth.client_type}).chat()
'client_type': request.auth.client_type,
'runtime_node_id': request.data.get('runtime_node_id', None),
'node_data': request.data.get('node_data', {}),
'chat_record_id': request.data.get('chat_record_id')}
).chat()
@action(methods=['GET'], detail=False)
@swagger_auto_schema(operation_summary="获取对话列表",

View File

@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='file',
name='meta',
field=models.JSONField(default={}, verbose_name='文件关联数据'),
field=models.JSONField(default=dict, verbose_name='文件关联数据'),
),
]

5
ui/env.d.ts vendored
View File

@ -8,7 +8,10 @@ declare module 'markdown-it-sub'
declare module 'markdown-it-sup'
declare module 'markdown-it-toc-done-right'
declare module 'katex'
interface Window {
sendMessage: ?((message: string, other_params_data: any) => void)
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare type Recordable<T = any> = Record<string, T>;
declare type Recordable<T = any> = Record<string, T>

View File

@ -27,6 +27,7 @@ interface chatType {
problem_text: string
answer_text: string
buffer: Array<String>
answer_text_list: Array<string>
/**
*
*/
@ -36,6 +37,7 @@ interface chatType {
*/
is_stop?: boolean
record_id: string
chat_id: string
vote_status: string
status?: number
}
@ -56,18 +58,25 @@ export class ChatRecordManage {
this.is_close = false
this.write_ed = false
}
append_answer(chunk_answer: String) {
this.chat.answer_text_list[this.chat.answer_text_list.length - 1] =
this.chat.answer_text_list[this.chat.answer_text_list.length - 1] + chunk_answer
this.chat.answer_text = this.chat.answer_text + chunk_answer
}
write() {
this.chat.is_stop = false
this.is_stop = false
this.is_close = false
this.write_ed = false
this.chat.write_ed = false
if (this.loading) {
this.loading.value = true
}
this.id = setInterval(() => {
if (this.chat.buffer.length > 20) {
this.chat.answer_text =
this.chat.answer_text + this.chat.buffer.splice(0, this.chat.buffer.length - 20).join('')
this.append_answer(this.chat.buffer.splice(0, this.chat.buffer.length - 20).join(''))
} else if (this.is_close) {
this.chat.answer_text = this.chat.answer_text + this.chat.buffer.splice(0).join('')
this.append_answer(this.chat.buffer.splice(0).join(''))
this.chat.write_ed = true
this.write_ed = true
if (this.loading) {
@ -79,7 +88,7 @@ export class ChatRecordManage {
} else {
const s = this.chat.buffer.shift()
if (s !== undefined) {
this.chat.answer_text = this.chat.answer_text + s
this.append_answer(s)
}
}
}, this.ms)
@ -95,6 +104,10 @@ export class ChatRecordManage {
close() {
this.is_close = true
}
open() {
this.is_close = false
this.is_stop = false
}
append(answer_text_block: string) {
for (let index = 0; index < answer_text_block.length; index++) {
this.chat.buffer.push(answer_text_block[index])

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.05704 15.3334H2.00004C1.82323 15.3334 1.65366 15.2632 1.52864 15.1382C1.40361 15.0131 1.33337 14.8436 1.33337 14.6667V1.33341C1.33337 1.1566 1.40361 0.987035 1.52864 0.862011C1.65366 0.736987 1.82323 0.666748 2.00004 0.666748H12.6667C12.8435 0.666748 13.0131 0.736987 13.1381 0.862011C13.2631 0.987035 13.3334 1.1566 13.3334 1.33341V6.05641C13.3334 6.23316 13.2633 6.4027 13.1384 6.52775L4.52871 15.1381C4.46678 15.2 4.39324 15.2492 4.31231 15.2827C4.23138 15.3162 4.14464 15.3334 4.05704 15.3334ZM4.431 7.90245C4.49352 7.96496 4.5783 8.00008 4.66671 8.00008H8.00004C8.08844 8.00008 8.17323 7.96496 8.23574 7.90245C8.29825 7.83994 8.33337 7.75515 8.33337 7.66675V7.00008C8.33337 6.91167 8.29825 6.82689 8.23574 6.76438C8.17323 6.70187 8.08844 6.66675 8.00004 6.66675H4.66671C4.5783 6.66675 4.49352 6.70187 4.431 6.76438C4.36849 6.82689 4.33337 6.91167 4.33337 7.00008V7.66675C4.33337 7.75515 4.36849 7.83994 4.431 7.90245ZM4.431 4.56912C4.49352 4.63163 4.5783 4.66675 4.66671 4.66675H10.3334C10.3771 4.66675 10.4205 4.65813 10.4609 4.64138C10.5014 4.62462 10.5381 4.60007 10.5691 4.56912C10.6 4.53816 10.6246 4.50142 10.6413 4.46098C10.6581 4.42053 10.6667 4.37719 10.6667 4.33341V3.66675C10.6667 3.62297 10.6581 3.57963 10.6413 3.53919C10.6246 3.49874 10.6 3.462 10.5691 3.43105C10.5381 3.40009 10.5014 3.37554 10.4609 3.35879C10.4205 3.34204 10.3771 3.33341 10.3334 3.33341H4.66671C4.5783 3.33341 4.49352 3.36853 4.431 3.43105C4.36849 3.49356 4.33337 3.57834 4.33337 3.66675V4.33341C4.33337 4.42182 4.36849 4.5066 4.431 4.56912Z" fill="white"/>
<g opacity="0.5">
<path d="M13.565 11.518L11.6847 9.6381L7.55305 13.7961L7.33337 15.5777C7.33337 15.6661 7.36849 15.7509 7.431 15.8134C7.49352 15.8759 7.5783 15.911 7.66671 15.911L9.45064 15.627L13.565 11.518Z" fill="white"/>
<path d="M14.0486 8.20917C13.7886 7.94884 13.387 7.92884 13.151 8.16417L12.1543 9.16417L14.0369 11.0468L15.0346 10.0508L15.0662 10.0168C15.2689 9.7781 15.2396 9.4001 14.9912 9.15144L14.0486 8.20917Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,94 @@
<template>
<div class="item-content mb-16 lighter">
<div v-for="(answer_text, index) in chatRecord.answer_text_list">
<div class="avatar">
<img v-if="application.avatar" :src="application.avatar" height="30px" />
<LogoIcon v-else height="30px" />
</div>
<div class="content">
<el-card shadow="always" class="dialog-card">
<MdRenderer
v-if="answer_text"
:source="answer_text"
:send-message="chatMessage"
></MdRenderer>
<span v-else-if="chatRecord.is_stop" shadow="always" class="dialog-card">
已停止回答
</span>
<span v-else shadow="always" class="dialog-card">
回答中 <span class="dotting"></span>
</span>
<!-- 知识来源 -->
<div v-if="showSource(chatRecord) && index === chatRecord.answer_text_list.length - 1">
<KnowledgeSource :data="chatRecord" :type="application.type" />
</div>
</el-card>
</div>
</div>
<OperationButton
:type="type"
:application="application"
:chat-record="chatRecord"
:loading="loading"
:start-chat="startChat"
:stop-chat="stopChat"
:regenerationChart="regenerationChart"
></OperationButton>
</div>
</template>
<script setup lang="ts">
import KnowledgeSource from '@/components/ai-chat/KnowledgeSource.vue'
import MdRenderer from '@/components/markdown/MdRenderer.vue'
import OperationButton from '@/components/ai-chat/component/operation-button/index.vue'
import { type chatType } from '@/api/type/application'
const props = defineProps<{
chatRecord: chatType
application: any
loading: boolean
sendMessage: (question: string, other_params_data?: any, chat?: chatType) => void
chatManagement: any
type: 'log' | 'ai-chat' | 'debug-ai-chat'
}>()
const chatMessage = (question: string, type: 'old' | 'new', other_params_data?: any) => {
if (type === 'old') {
props.chatRecord.answer_text_list.push('')
props.sendMessage(question, other_params_data, props.chatRecord)
props.chatManagement.write(props.chatRecord.id)
} else {
props.sendMessage(question, other_params_data)
}
}
function showSource(row: any) {
if (props.type === 'log') {
return true
} else if (row.write_ed && 500 !== row.status) {
if (props.type === 'debug-ai-chat' || props.application?.show_source) {
return true
}
}
return false
}
const regenerationChart = (question: string) => {
props.sendMessage(question, { rechat: true })
}
const stopChat = (chat: chatType) => {
props.chatManagement.stop(chat.id)
}
const startChat = (chat: chatType) => {
props.chatManagement.write(chat.id)
}
</script>
<style lang="scss" scoped>
.avatar {
float: left;
}
.content {
padding-left: var(--padding-left);
:deep(ol) {
margin-left: 16px !important;
}
}
</style>

View File

@ -0,0 +1,392 @@
<template>
<div></div>
<div class="ai-chat__operate p-16-24">
<slot name="operateBefore" />
<div class="operate-textarea flex chat-width">
<el-input
ref="quickInputRef"
v-model="inputValue"
:placeholder="
startRecorderTime
? '说话中...'
: recorderLoading
? '转文字中...'
: '请输入问题Ctrl+Enter 换行Enter发送'
"
:autosize="{ minRows: 1, maxRows: isMobile ? 4 : 10 }"
type="textarea"
:maxlength="100000"
@keydown.enter="sendChatHandle($event)"
/>
<div class="operate flex align-center">
<span v-if="props.applicationDetails.file_upload_enable" class="flex align-center">
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="(file: any, fileList: any) => uploadFile(file, fileList)"
>
<el-button text>
<el-icon><Paperclip /></el-icon>
</el-button>
</el-upload>
<el-divider direction="vertical" />
</span>
<span v-if="props.applicationDetails.stt_model_enable" class="flex align-center">
<el-button text v-if="mediaRecorderStatus" @click="startRecording">
<el-icon>
<Microphone />
</el-icon>
</el-button>
<div v-else class="operate flex align-center">
<el-text type="info"
>00:{{ recorderTime < 10 ? `0${recorderTime}` : recorderTime }}</el-text
>
<el-button text type="primary" @click="stopRecording" :loading="recorderLoading">
<AppIcon iconName="app-video-stop"></AppIcon>
</el-button>
</div>
<el-divider v-if="!startRecorderTime && !recorderLoading" direction="vertical" />
</span>
<el-button
v-if="!startRecorderTime && !recorderLoading"
text
class="sent-button"
:disabled="isDisabledChart || loading"
@click="sendChatHandle"
>
<img v-show="isDisabledChart || loading" src="@/assets/icon_send.svg" alt="" />
<SendIcon v-show="!isDisabledChart && !loading" />
</el-button>
</div>
</div>
<div
class="chat-width text-center"
v-if="applicationDetails.disclaimer"
style="margin-top: 8px"
>
<el-text type="info" v-if="applicationDetails.disclaimer" style="font-size: 12px">
<auto-tooltip :content="applicationDetails.disclaimer_value">
{{ applicationDetails.disclaimer_value }}
</auto-tooltip>
</el-text>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import Recorder from 'recorder-core'
import applicationApi from '@/api/application'
import { MsgAlert } from '@/utils/message'
import { type chatType } from '@/api/type/application'
import { useRoute } from 'vue-router'
import 'recorder-core/src/engine/mp3'
import 'recorder-core/src/engine/mp3-engine'
import { MsgWarning } from '@/utils/message'
const route = useRoute()
const {
query: { mode }
} = route as any
const quickInputRef = ref()
const props = withDefaults(
defineProps<{
applicationDetails: any
type: 'log' | 'ai-chat' | 'debug-ai-chat'
loading: boolean
isMobile: boolean
appId?: string
chatId: string
sendMessage: (question: string, other_params_data?: any, chat?: chatType) => void
}>(),
{
applicationDetails: () => ({}),
available: true
}
)
const emit = defineEmits(['update:chatId', 'update:loading'])
const chartOpenId = ref<string>()
const chatId_context = computed({
get: () => {
if (chartOpenId.value) {
return chartOpenId.value
}
return props.chatId
},
set: (v) => {
chartOpenId.value = v
emit('update:chatId', v)
}
})
const localLoading = computed({
get: () => {
return props.loading
},
set: (v) => {
emit('update:loading', v)
}
})
const uploadFile = async (file: any, fileList: any) => {
const { maxFiles, fileLimit } = props.applicationDetails.file_upload_setting
if (fileList.length > maxFiles) {
MsgWarning('最多上传' + maxFiles + '个文件')
return
}
if (fileList.filter((f: any) => f.size > fileLimit * 1024 * 1024).length > 0) {
// MB
MsgWarning('单个文件大小不能超过' + fileLimit + 'MB')
fileList.splice(0, fileList.length)
return
}
const formData = new FormData()
for (const file of fileList) {
formData.append('file', file.raw, file.name)
uploadFileList.value.push(file)
}
if (!chatId_context.value) {
const res = await applicationApi.getChatOpen(props.applicationDetails.id as string)
chatId_context.value = res.data
}
applicationApi
.uploadFile(
props.applicationDetails.id as string,
chatId_context.value as string,
formData,
localLoading
)
.then((response) => {
fileList.splice(0, fileList.length)
uploadFileList.value.forEach((file: any) => {
const f = response.data.filter((f: any) => f.name === file.name)
if (f.length > 0) {
file.url = f[0].url
file.file_id = f[0].file_id
}
})
console.log(uploadFileList.value)
})
}
const recorderTime = ref(0)
const startRecorderTime = ref(false)
const recorderLoading = ref(false)
const inputValue = ref<string>('')
const uploadFileList = ref<Array<any>>([])
const mediaRecorderStatus = ref(true)
//
const mediaRecorder = ref<any>(null)
const isDisabledChart = computed(
() => !(inputValue.value.trim() && (props.appId || props.applicationDetails?.name))
)
//
const startRecording = async () => {
try {
//
Recorder.CLog = function () {}
mediaRecorderStatus.value = false
handleTimeChange()
mediaRecorder.value = new Recorder({
type: 'mp3',
bitRate: 128,
sampleRate: 16000
})
mediaRecorder.value.open(
() => {
mediaRecorder.value.start()
},
(err: any) => {
MsgAlert(
`提示`,
`<p>该功能需要使用麦克风,浏览器禁止不安全页面录音,解决方案如下:<br/>
1可开启 https 解决<br/>
2若无 https 配置则需要修改浏览器安全配置Chrome 设置如下<br/>
(1) 地址栏输入chrome://flags/#unsafely-treat-insecure-origin-as-secure<br/>
(2) http 站点配置在文本框中例如: http://127.0.0.1:8080</p>
<img src="${new URL(`../../assets/tipIMG.jpg`, import.meta.url).href}" style="width: 100%;" />`,
{
confirmButtonText: '我知道了',
dangerouslyUseHTMLString: true,
customClass: 'record-tip-confirm'
}
)
}
)
} catch (error) {
MsgAlert(
`提示`,
`<p>该功能需要使用麦克风,浏览器禁止不安全页面录音,解决方案如下:<br/>
1可开启 https 解决<br/>
2若无 https 配置则需要修改浏览器安全配置Chrome 设置如下<br/>
(1) 地址栏输入chrome://flags/#unsafely-treat-insecure-origin-as-secure<br/>
(2) http 站点配置在文本框中例如: http://127.0.0.1:8080</p>
<img src="${new URL(`../../assets/tipIMG.jpg`, import.meta.url).href}" style="width: 100%;" />`,
{
confirmButtonText: '我知道了',
dangerouslyUseHTMLString: true,
customClass: 'record-tip-confirm'
}
)
}
}
//
const stopRecording = () => {
startRecorderTime.value = false
recorderTime.value = 0
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()
uploadRecording(blob) //
},
(err: any) => {
console.error('录音失败:', err)
}
)
}
}
//
const uploadRecording = async (audioBlob: Blob) => {
try {
recorderLoading.value = true
const formData = new FormData()
formData.append('file', audioBlob, 'recording.mp3')
applicationApi
.postSpeechToText(props.applicationDetails.id as string, formData, localLoading)
.then((response) => {
recorderLoading.value = false
mediaRecorder.value.close()
inputValue.value = typeof response.data === 'string' ? response.data : ''
// chatMessage(null, res.data)
})
} catch (error) {
recorderLoading.value = false
console.error('上传失败:', error)
}
}
const handleTimeChange = () => {
startRecorderTime.value = true
setTimeout(() => {
if (recorderTime.value === 60) {
recorderTime.value = 0
stopRecording()
startRecorderTime.value = false
}
if (!startRecorderTime.value) {
return
}
recorderTime.value++
handleTimeChange()
}, 1000)
}
function sendChatHandle(event: any) {
if (!event.ctrlKey) {
// ctrl
event.preventDefault()
if (!isDisabledChart.value && !props.loading && !event.isComposing) {
if (inputValue.value.trim()) {
props.sendMessage(inputValue.value, { image_list: uploadFileList.value })
inputValue.value = ''
uploadFileList.value = []
quickInputRef.value.textareaStyle.height = '45px'
}
}
} else {
// ctrl+
inputValue.value += '\n'
}
}
onMounted(() => {
setTimeout(() => {
if (quickInputRef.value && mode === 'embed') {
quickInputRef.value.textarea.style.height = '0'
}
}, 1800)
})
</script>
<style lang="scss" scope>
.ai-chat {
&__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: '';
position: absolute;
width: 100%;
top: -16px;
left: 0;
height: 16px;
}
.operate-textarea {
box-shadow: 0px 6px 24px 0px rgba(31, 35, 41, 0.08);
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #ffffff;
box-sizing: border-box;
&:has(.el-textarea__inner:focus) {
border: 1px solid var(--el-color-primary);
}
.el-textarea__inner {
border-radius: 8px !important;
box-shadow: none;
resize: none;
padding: 12px 16px;
box-sizing: border-box;
}
.operate {
padding: 6px 10px;
.el-icon {
font-size: 20px;
}
.sent-button {
max-height: none;
.el-icon {
font-size: 24px;
}
}
.el-loading-spinner {
margin-top: -15px;
.circular {
width: 31px;
height: 31px;
}
}
}
}
}
}
.chat-width {
max-width: 80%;
margin: 0 auto;
}
@media only screen and (max-width: 1000px) {
.chat-width {
max-width: 100% !important;
margin: 0 auto;
}
}
</style>

View File

@ -19,7 +19,7 @@
</el-tooltip>
<el-divider direction="vertical" />
</span>
<span v-if="applicationId">
<span v-if="applicationId && type == 'log'">
<el-tooltip effect="dark" content="换个答案" placement="top">
<el-button :disabled="chat_loading" text @click="regeneration">
<el-icon><RefreshRight /></el-icon>
@ -90,26 +90,21 @@ const {
params: { id }
} = route as any
const props = defineProps({
data: {
type: Object,
default: () => {}
},
applicationId: {
type: String,
default: ''
},
chatId: {
type: String,
default: ''
},
chat_loading: {
type: Boolean
},
log: Boolean,
tts: Boolean,
tts_type: String
})
const props = withDefaults(
defineProps<{
data: any
type: 'log' | 'ai-chat' | 'debug-ai-chat'
chatId: string
chat_loading: boolean
applicationId: string
tts: boolean
tts_type: string
}>(),
{
data: () => ({}),
type: 'ai-chat'
}
)
const emit = defineEmits(['update:data', 'regeneration'])

View File

@ -1,59 +1,61 @@
<template>
<div>
<el-text type="info">
<span class="ml-4">{{ datetimeFormat(data.create_time) }}</span>
</el-text>
</div>
<div>
<!-- 语音播放 -->
<span v-if="tts">
<el-tooltip effect="dark" content="点击播放" placement="top" v-if="!audioPlayerStatus">
<el-button text @click="playAnswerText(data?.answer_text)">
<AppIcon iconName="app-video-play"></AppIcon>
</el-button>
</el-tooltip>
<el-tooltip v-else effect="dark" content="停止" placement="top">
<el-button type="primary" text @click="pausePlayAnswerText()">
<AppIcon iconName="app-video-pause"></AppIcon>
<div class="flex-between mt-8">
<div>
<el-text type="info">
<span class="ml-4">{{ datetimeFormat(data.create_time) }}</span>
</el-text>
</div>
<div>
<!-- 语音播放 -->
<span v-if="tts">
<el-tooltip effect="dark" content="点击播放" placement="top" v-if="!audioPlayerStatus">
<el-button text @click="playAnswerText(data?.answer_text)">
<AppIcon iconName="app-video-play"></AppIcon>
</el-button>
</el-tooltip>
<el-tooltip v-else effect="dark" content="停止" placement="top">
<el-button type="primary" text @click="pausePlayAnswerText()">
<AppIcon iconName="app-video-pause"></AppIcon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" />
</span>
<el-tooltip effect="dark" content="复制" placement="top">
<el-button text @click="copyClick(data?.answer_text)">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" />
</span>
<el-tooltip effect="dark" content="复制" placement="top">
<el-button text @click="copyClick(data?.answer_text)">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip
v-if="data.improve_paragraph_id_list.length === 0"
effect="dark"
content="修改内容"
placement="top"
>
<el-button text @click="editContent(data)">
<el-icon><EditPen /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip
v-if="data.improve_paragraph_id_list.length === 0"
effect="dark"
content="修改内容"
placement="top"
>
<el-button text @click="editContent(data)">
<el-icon><EditPen /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-else effect="dark" content="修改标注" placement="top">
<el-button text @click="editMark(data)">
<AppIcon iconName="app-document-active" class="primary"></AppIcon>
<el-tooltip v-else effect="dark" content="修改标注" placement="top">
<el-button text @click="editMark(data)">
<AppIcon iconName="app-document-active" class="primary"></AppIcon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" v-if="buttonData?.vote_status !== '-1'" />
<el-button text disabled v-if="buttonData?.vote_status === '0'">
<AppIcon iconName="app-like-color"></AppIcon>
</el-button>
</el-tooltip>
<el-divider direction="vertical" v-if="buttonData?.vote_status !== '-1'" />
<el-button text disabled v-if="buttonData?.vote_status === '0'">
<AppIcon iconName="app-like-color"></AppIcon>
</el-button>
<el-button text disabled v-if="buttonData?.vote_status === '1'">
<AppIcon iconName="app-oppose-color"></AppIcon>
</el-button>
<EditContentDialog ref="EditContentDialogRef" @refresh="refreshContent" />
<EditMarkDialog ref="EditMarkDialogRef" @refresh="refreshMark" />
<!-- 先渲染不然不能播放 -->
<audio ref="audioPlayer" controls hidden="hidden"></audio>
<el-button text disabled v-if="buttonData?.vote_status === '1'">
<AppIcon iconName="app-oppose-color"></AppIcon>
</el-button>
<EditContentDialog ref="EditContentDialogRef" @refresh="refreshContent" />
<EditMarkDialog ref="EditMarkDialogRef" @refresh="refreshMark" />
<!-- 先渲染不然不能播放 -->
<audio ref="audioPlayer" controls hidden="hidden"></audio>
</div>
</div>
</template>
<script setup lang="ts">

View File

@ -0,0 +1,58 @@
<template>
<div>
<LogOperationButton
v-if="type === 'log'"
v-bind:data="chatRecord"
:applicationId="application.id"
:tts="application.tts_model_enable"
:tts_type="application.tts_type"
:type="type"
/>
<div class="flex-between mt-8" v-else>
<div>
<el-button
type="primary"
v-if="chatRecord.is_stop && !chatRecord.write_ed"
@click="startChat(chatRecord)"
link
>继续
</el-button>
<el-button
type="primary"
v-else-if="!chatRecord.write_ed"
@click="stopChat(chatRecord)"
link
>停止回答
</el-button>
</div>
</div>
<div v-if="chatRecord.write_ed && 500 != chatRecord.status" class="flex-between">
<ChatOperationButton
:tts="application.tts_model_enable"
:tts_type="application.tts_type"
:data="chatRecord"
:type="type"
:applicationId="application.id"
:chatId="chatRecord.chat_id"
:chat_loading="loading"
@regeneration="regenerationChart(chatRecord)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import ChatOperationButton from '@/components/ai-chat/component/operation-button/ChatOperationButton.vue'
import LogOperationButton from '@/components/ai-chat/component/operation-button/LogOperationButton.vue'
import { type chatType } from '@/api/type/application'
defineProps<{
type: 'log' | 'ai-chat' | 'debug-ai-chat'
chatRecord: chatType
application: any
loading: boolean
startChat: (chat_record: any) => void
stopChat: (chat_record: any) => void
regenerationChart: (chat_record: any) => void
}>()
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,35 @@
<template>
<!-- 开场白组件 -->
<div class="item-content mb-16">
<div class="avatar">
<img v-if="application.avatar" :src="application.avatar" height="30px" />
<LogoIcon v-else height="30px" />
</div>
<div class="content">
<el-card shadow="always" class="dialog-card">
<MdRenderer :source="prologue" :send-message="sendMessage"></MdRenderer>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { type chatType } from '@/api/type/application'
import { computed } from 'vue'
import MdRenderer from '@/components/markdown/MdRenderer.vue'
const props = defineProps<{
application: any
available: boolean
type: 'log' | 'ai-chat' | 'debug-ai-chat'
sendMessage: (question: string, other_params_data?: any, chat?: chatType) => void
}>()
const toQuickQuestion = (match: string, offset: number, input: string) => {
return `<quick_question>${match.replace('- ', '')}</quick_question>`
}
const prologue = computed(() => {
const temp = props.available
? props.application?.prologue
: '抱歉,当前正在维护,无法提供服务,请稍后再试!'
return temp.replace(/-\s.+/g, toQuickQuestion)
})
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,81 @@
<template>
<!-- 问题内容 -->
<div class="item-content mb-16 lighter">
<div class="avatar">
<el-image
v-if="application.user_avatar"
:src="application.user_avatar"
alt=""
fit="cover"
style="width: 30px; height: 30px; display: block"
/>
<AppAvatar v-else>
<img src="@/assets/user-icon.svg" style="width: 54%" alt="" />
</AppAvatar>
</div>
<div class="content">
<div class="text break-all pre-wrap">
{{ chatRecord.problem_text }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { type chatType } from '@/api/type/application'
defineProps<{
application: any
chatRecord: chatType
}>()
</script>
<style lang="scss" scoped>
.ai-chat {
&__content {
padding-top: 0;
box-sizing: border-box;
.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;
border-radius: 8px;
background: var(--app-layout-bg-color);
height: 46px;
padding: 0 12px;
line-height: 46px;
box-sizing: border-box;
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);
}
}
.el-icon {
color: var(--el-color-primary);
}
}
}
}
</style>

View File

@ -0,0 +1,304 @@
<template>
<div
v-if="
(inputFieldList.length > 0 || (type === 'debug-ai-chat' && apiInputFieldList.length > 0)) &&
type !== 'log'
"
class="mb-16"
style="padding: 0 24px"
>
<el-card shadow="always" class="dialog-card">
<div class="flex align-center cursor w-full" @click="showUserInput = !showUserInput">
<el-icon class="mr-8 arrow-icon" :class="showUserInput ? 'rotate-90' : ''"
><CaretRight
/></el-icon>
用户输入
</div>
<el-collapse-transition>
<div v-show="showUserInput" class="mt-16">
<DynamicsForm
:key="dynamicsFormRefresh"
v-model="form_data_context"
:model="form_data_context"
label-position="left"
require-asterisk-position="right"
:render_data="inputFieldList"
ref="dynamicsFormRef"
/>
<DynamicsForm
v-if="type === 'debug-ai-chat'"
v-model="api_form_data_context"
:model="api_form_data_context"
label-position="left"
require-asterisk-position="right"
:render_data="apiInputFieldList"
ref="dynamicsFormRef2"
/>
</div>
</el-collapse-transition>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import DynamicsForm from '@/components/dynamics-form/index.vue'
import type { FormField } from '@/components/dynamics-form/type'
import { useRoute } from 'vue-router'
import { MsgWarning } from '@/utils/message'
const route = useRoute()
const props = defineProps<{
application: any
type: 'log' | 'ai-chat' | 'debug-ai-chat'
api_form_data: any
form_data: any
}>()
//
const dynamicsFormRefresh = ref(0)
const inputFieldList = ref<FormField[]>([])
const apiInputFieldList = ref<FormField[]>([])
const showUserInput = ref(true)
const emit = defineEmits(['update:api_form_data', 'update:form_data'])
const api_form_data_context = computed({
get: () => {
return props.api_form_data
},
set: (data) => {
emit('update:api_form_data', data)
}
})
const form_data_context = computed({
get: () => {
return props.form_data
},
set: (data) => {
emit('update:form_data', data)
}
})
watch(
() => props.application,
() => {
handleInputFieldList()
}
)
function handleInputFieldList() {
dynamicsFormRefresh.value++
let default_value: any = {}
props.application.work_flow?.nodes
?.filter((v: any) => v.id === 'base-node')
.map((v: any) => {
inputFieldList.value = v.properties.user_input_field_list
? v.properties.user_input_field_list.map((v: any) => {
switch (v.type) {
case 'input':
return {
field: v.variable,
input_type: 'TextInput',
label: v.name,
default_value: default_value[v.variable],
required: v.is_required
}
case 'select':
return {
field: v.variable,
input_type: 'SingleSelect',
label: v.name,
default_value: default_value[v.variable],
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,
default_value: default_value[v.variable],
required: v.is_required,
attrs: {
format: 'YYYY-MM-DD HH:mm:ss',
'value-format': 'YYYY-MM-DD HH:mm:ss',
type: 'datetime'
}
}
default:
return v
}
})
: v.properties.input_field_list
? v.properties.input_field_list
.filter((v: any) => v.assignment_method === 'user_input')
.map((v: any) => {
switch (v.type) {
case 'input':
return {
field: v.variable,
input_type: 'TextInput',
label: v.name,
default_value: default_value[v.variable],
required: v.is_required
}
case 'select':
return {
field: v.variable,
input_type: 'SingleSelect',
label: v.name,
default_value: default_value[v.variable],
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,
default_value: default_value[v.variable],
required: v.is_required,
attrs: {
format: 'YYYY-MM-DD HH:mm:ss',
'value-format': 'YYYY-MM-DD HH:mm:ss',
type: 'datetime'
}
}
default:
break
}
})
: []
apiInputFieldList.value = v.properties.api_input_field_list
? v.properties.api_input_field_list.map((v: any) => {
switch (v.type) {
case 'input':
return {
field: v.variable,
input_type: 'TextInput',
label: v.variable,
default_value: v.default_value || default_value[v.variable],
required: v.is_required
}
case 'select':
return {
field: v.variable,
input_type: 'SingleSelect',
label: v.variable,
default_value: v.default_value || default_value[v.variable],
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.variable,
default_value: v.default_value || default_value[v.variable],
required: v.is_required,
attrs: {
format: 'YYYY-MM-DD HH:mm:ss',
'value-format': 'YYYY-MM-DD HH:mm:ss',
type: 'datetime'
}
}
default:
break
}
})
: v.properties.input_field_list
? v.properties.input_field_list
.filter((v: any) => v.assignment_method === 'api_input')
.map((v: any) => {
switch (v.type) {
case 'input':
return {
field: v.variable,
input_type: 'TextInput',
label: v.name,
default_value: default_value[v.variable],
required: v.is_required
}
case 'select':
return {
field: v.variable,
input_type: 'SingleSelect',
label: v.name,
default_value: default_value[v.variable],
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,
default_value: default_value[v.variable],
required: v.is_required,
attrs: {
format: 'YYYY-MM-DD HH:mm:ss',
'value-format': 'YYYY-MM-DD HH:mm:ss',
type: 'datetime'
}
}
default:
break
}
})
: []
})
}
/**
* 校验参数
*/
const checkInputParam = () => {
// inputFieldList
for (let i = 0; i < inputFieldList.value.length; i++) {
if (
inputFieldList.value[i].required &&
!form_data_context.value[inputFieldList.value[i].field]
) {
MsgWarning('请填写所有必填字段')
return false
}
}
// query
let msg = []
for (let f of apiInputFieldList.value) {
if (!api_form_data_context.value[f.field]) {
api_form_data_context.value[f.field] = route.query[f.field]
}
if (f.required && !api_form_data_context.value[f.field]) {
msg.push(f.field)
}
}
if (msg.length > 0) {
MsgWarning(`请在URL中填写参数 ${msg.join('、')}的值`)
return false
}
return true
}
defineExpose({ checkInputParam })
onMounted(() => {
handleInputFieldList()
})
</script>
<style lang="scss" scoped>
.chat-width {
max-width: 80%;
margin: 0 auto;
}
@media only screen and (max-width: 1000px) {
.chat-width {
max-width: 100% !important;
margin: 0 auto;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -154,7 +154,7 @@ const initDefaultData = (formField: FormField) => {
}
onBeforeMount(() => {
render(props.render_data, {})
render(props.render_data, props.modelValue)
})
const render = (

View File

@ -0,0 +1,79 @@
<template>
<div>
<DynamicsForm
:disabled="is_submit"
label-position="top"
require-asterisk-position="right"
ref="dynamicsFormRef"
:render_data="form_field_list"
label-suffix=":"
v-model="form_data"
:model="form_data"
></DynamicsForm>
<el-button :type="is_submit ? 'info' : 'primary'" :disabled="is_submit" @click="submit"
>提交</el-button
>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import DynamicsForm from '@/components/dynamics-form/index.vue'
const props = defineProps<{
form_setting: string
sendMessage: (question: string, type: 'old' | 'new', other_params_data?: any) => void
}>()
const form_setting_data = computed(() => {
if (props.form_setting) {
return JSON.parse(props.form_setting)
} else {
return {}
}
})
const _submit = ref<boolean>(false)
/**
* 表单字段列表
*/
const form_field_list = computed(() => {
if (form_setting_data.value.form_field_list) {
return form_setting_data.value.form_field_list
}
return []
})
const is_submit = computed(() => {
if (_submit.value) {
return true
}
if (form_setting_data.value.is_submit) {
return form_setting_data.value.is_submit
} else {
return false
}
})
const _form_data = ref<any>({})
const form_data = computed({
get: () => {
console.log(form_setting_data.value)
if (form_setting_data.value.is_submit) {
return form_setting_data.value.form_data
} else {
return _form_data.value
}
},
set: (v) => {
_form_data.value = v
}
})
const dynamicsFormRef = ref<InstanceType<typeof DynamicsForm>>()
const submit = () => {
dynamicsFormRef.value?.validate().then(() => {
_submit.value = true
const setting = JSON.parse(props.form_setting)
props.sendMessage('', 'old', {
runtime_node_id: setting.runtime_node_id,
chat_record_id: setting.chat_record_id,
node_data: form_data.value
})
})
}
</script>
<style lang="scss" scoped></style>

View File

@ -2,9 +2,9 @@
<template v-for="(item, index) in md_view_list" :key="index">
<div
v-if="item.type === 'question'"
@click="quickProblemHandle ? quickProblemHandle(item.content) : (content: string) => {}"
@click="sendMessage ? sendMessage(item.content, 'new') : (content: string) => {}"
class="problem-button ellipsis-2 mb-8"
:class="quickProblemHandle ? 'cursor' : 'disabled'"
:class="sendMessage ? 'cursor' : 'disabled'"
>
<el-icon><EditPen /></el-icon>
{{ item.content }}
@ -14,6 +14,11 @@
v-else-if="item.type === 'echarts_rander'"
:option="item.content"
></EchartsRander>
<FormRander
:sendMessage="sendMessage"
v-else-if="item.type === 'form_rander'"
:form_setting="item.content"
></FormRander>
<MdPreview
v-else
noIconfont
@ -30,6 +35,7 @@ import { computed, ref } from 'vue'
import { config } from 'md-editor-v3'
import HtmlRander from './HtmlRander.vue'
import EchartsRander from './EchartsRander.vue'
import FormRander from './FormRander.vue'
config({
markdownItConfig(md) {
md.renderer.rules.image = (tokens, idx, options, env, self) => {
@ -54,7 +60,7 @@ const props = withDefaults(
defineProps<{
source?: string
inner_suffix?: boolean
quickProblemHandle?: (q: string) => void
sendMessage?: (question: string, type: 'old' | 'new', other_params_data?: any) => void
}>(),
{
source: ''
@ -63,7 +69,9 @@ const props = withDefaults(
const editorRef = ref()
const md_view_list = computed(() => {
const temp_source = props.source
return split_echarts_rander(split_html_rander(split_quick_question([temp_source])))
return split_form_rander(
split_echarts_rander(split_html_rander(split_quick_question([temp_source])))
)
})
const split_quick_question = (result: Array<string>) => {
@ -168,6 +176,41 @@ const split_echarts_rander_ = (source: string, type: string) => {
})
return result
}
const split_form_rander = (result: Array<any>) => {
return result
.map((item) => split_form_rander_(item.content, item.type))
.reduce((x: any, y: any) => {
return [...x, ...y]
}, [])
}
const split_form_rander_ = (source: string, type: string) => {
const temp_md_quick_question_list = source.match(/<form_rander>[\d\D]*?<\/form_rander>/g)
const md_quick_question_list = temp_md_quick_question_list
? temp_md_quick_question_list.filter((i) => i)
: []
const split_quick_question_value = source
.split(/<form_rander>[\d\D]*?<\/form_rander>/g)
.filter((item) => item !== undefined)
.filter((item) => !md_quick_question_list?.includes(item))
const result = Array.from(
{ length: md_quick_question_list.length + split_quick_question_value.length },
(v, i) => i
).map((index) => {
if (index % 2 == 0) {
return { type: type, content: split_quick_question_value[Math.floor(index / 2)] }
} else {
return {
type: 'form_rander',
content: md_quick_question_list[Math.floor(index / 2)]
.replace('<form_rander>', '')
.replace('</form_rander>', '')
}
}
})
return result
}
</script>
<style lang="scss" scoped>
.problem-button {

View File

@ -12,4 +12,5 @@ export enum WorkflowType {
Application = 'application-node',
DocumentExtractNode = 'document-extract-node',
ImageUnderstandNode = 'image-understand-node',
FormNode = 'form-node'
}

View File

@ -120,7 +120,7 @@
</div>
</div>
<div class="scrollbar-height">
<AiChat :data="detail" :debug="true"></AiChat>
<AiChat :application-details="detail" :type="'debug-ai-chat'"></AiChat>
</div>
</div>
</el-collapse-transition>

View File

@ -31,7 +31,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { isAppIcon } from '@/utils/application'
import { hexToRgba } from '@/utils/theme'
const auth_components: any = import.meta.glob('@/views/chat/auth/component/*.vue', {
eager: true
@ -40,9 +39,10 @@ const auth_components: any = import.meta.glob('@/views/chat/auth/component/*.vue
const emit = defineEmits(['update:modelValue'])
const props = withDefaults(
defineProps<{ modelValue: boolean; application_profile: any; auth_type?: string }>(),
defineProps<{ modelValue: boolean; application_profile: any; auth_type?: string; style?: any }>(),
{
auth_type: 'password'
auth_type: 'password',
style: {}
}
)
const is_auth = computed({
@ -58,7 +58,8 @@ const customStyle = computed(() => {
return {
background: props.application_profile?.custom_theme?.theme_color,
color: props.application_profile?.custom_theme?.header_font_color,
border: 'none'
border: 'none',
...props.style
}
})
</script>

View File

@ -25,7 +25,8 @@
</div>
<div class="chat__main chat-width">
<AiChat
v-model:data="applicationDetail"
v-model:applicationDetails="applicationDetail"
type="ai-chat"
:available="applicationAvailable"
:appId="applicationDetail?.id"
></AiChat>

View File

@ -34,11 +34,12 @@
<div class="chat-embed__main">
<AiChat
ref="AiChatRef"
v-model:data="applicationDetail"
v-model:applicationDetails="applicationDetail"
:available="applicationAvailable"
:appId="applicationDetail?.id"
:record="currentRecordList"
:chatId="currentChatId"
type="ai-chat"
@refresh="refresh"
@scroll="handleScroll"
class="AiChat-embed"

View File

@ -117,8 +117,9 @@
<div class="right-height">
<AiChat
ref="AiChatRef"
v-model:data="applicationDetail"
v-model:applicationDetails="applicationDetail"
:available="applicationAvailable"
type="ai-chat"
:appId="applicationDetail?.id"
:record="currentRecordList"
:chatId="currentChatId"

View File

@ -16,7 +16,7 @@
@load="getChatRecord"
:loading="loading"
>
<AiChat :data="application" :record="recordList" log></AiChat>
<AiChat :application-details="application" :record="recordList" type="log"></AiChat>
</InfiniteScroll>
</div>
<template #footer>

View File

@ -0,0 +1,52 @@
<template>
<el-dialog
:title="title"
v-model="dialogVisible"
:close-on-click-modal="false"
:close-on-press-escape="false"
:destroy-on-close="true"
:before-close="close"
append-to-body
>
<DynamicsFormConstructor
v-model="dynamicsFormData"
label-position="top"
require-asterisk-position="right"
ref="dynamicsFormConstructorRef"
></DynamicsFormConstructor>
<template #footer>
<span class="dialog-footer">
<el-button @click.prevent="dialogVisible = false"> 取消 </el-button>
<el-button type="primary" @click="submit()" :loading="loading"> 添加 </el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import DynamicsFormConstructor from '@/components/dynamics-form/constructor/index.vue'
const props = withDefaults(
defineProps<{ title?: string; addFormField: (form_data: any) => void }>(),
{ title: '添加参数' }
)
const dialogVisible = ref<boolean>(false)
const dynamicsFormConstructorRef = ref<InstanceType<typeof DynamicsFormConstructor>>()
const emit = defineEmits(['submit'])
const dynamicsFormData = ref<any>({})
const loading = ref<boolean>(false)
const open = () => {
dialogVisible.value = true
}
const close = () => {
dialogVisible.value = false
dynamicsFormData.value = {}
}
const submit = () => {
dynamicsFormConstructorRef.value?.validate().then(() => {
props.addFormField(dynamicsFormConstructorRef.value?.getData())
close()
})
}
defineExpose({ close, open })
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,55 @@
<template>
<el-dialog
:title="title"
v-model="dialogVisible"
:close-on-click-modal="false"
:close-on-press-escape="false"
:destroy-on-close="true"
:before-close="close"
append-to-body
>
<DynamicsFormConstructor
v-model="dynamicsFormData"
label-position="top"
require-asterisk-position="right"
ref="dynamicsFormConstructorRef"
></DynamicsFormConstructor>
<template #footer>
<span class="dialog-footer">
<el-button @click.prevent="dialogVisible = false"> 取消 </el-button>
<el-button type="primary" @click="submit()" :loading="loading"> 修改 </el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import DynamicsFormConstructor from '@/components/dynamics-form/constructor/index.vue'
const props = withDefaults(
defineProps<{ title?: string; editFormField: (form_data: any, index: number) => void }>(),
{ title: '修改参数' }
)
const dialogVisible = ref<boolean>(false)
const dynamicsFormConstructorRef = ref<InstanceType<typeof DynamicsFormConstructor>>()
const emit = defineEmits(['submit'])
const dynamicsFormData = ref<any>({})
const currentIndex = ref<number>(0)
const loading = ref<boolean>(false)
const open = (form_data: any, index: number) => {
dialogVisible.value = true
dynamicsFormData.value = form_data
currentIndex.value = index
}
const close = () => {
dialogVisible.value = false
dynamicsFormData.value = {}
}
const submit = () => {
dynamicsFormConstructorRef.value?.validate().then(() => {
props.editFormField(dynamicsFormConstructorRef.value?.getData(), currentIndex.value)
close()
})
}
defineExpose({ close, open })
</script>
<style lang="scss" scoped></style>

View File

@ -168,6 +168,31 @@ export const rerankerNode = {
}
}
}
export const formNode = {
type: WorkflowType.FormNode,
text: '在问答过程中用于收集用户信息,可以根据收集到表单数据执行后续流程',
label: '表单收集',
height: 252,
properties: {
width: 600,
stepName: '表单收集',
node_data: {
is_result: true,
form_field_list: [],
form_content_format: `你好,请先填写下面表单内容:
{{form}}
`
},
config: {
fields: [
{
label: '表单全部内容',
value: 'form_data'
}
]
}
}
}
export const documentExtractNode = {
type: WorkflowType.DocumentExtractNode,
text: '提取文档中的内容',
@ -180,7 +205,7 @@ export const documentExtractNode = {
{
label: '文件内容',
value: 'content'
},
}
]
}
}
@ -197,7 +222,7 @@ export const imageUnderstandNode = {
{
label: 'AI 回答内容',
value: 'content'
},
}
]
}
}
@ -208,9 +233,7 @@ export const menuNodes = [
questionNode,
conditionNode,
replyNode,
rerankerNode,
documentExtractNode,
imageUnderstandNode
rerankerNode
]
/**
@ -297,9 +320,10 @@ export const nodeDict: any = {
[WorkflowType.FunctionLib]: functionLibNode,
[WorkflowType.FunctionLibCustom]: functionNode,
[WorkflowType.RrerankerNode]: rerankerNode,
[WorkflowType.FormNode]: formNode,
[WorkflowType.Application]: applicationNode,
[WorkflowType.DocumentExtractNode]: documentExtractNode,
[WorkflowType.ImageUnderstandNode]: imageUnderstandNode,
[WorkflowType.ImageUnderstandNode]: imageUnderstandNode
}
export function isWorkFlow(type: string | undefined) {
return type === 'WORK_FLOW'

View File

@ -0,0 +1,6 @@
<template>
<AppAvatar shape="square" style="background: #34c724">
<img src="@/assets/icon_form.svg" style="width: 75%" alt="" />
</AppAvatar>
</template>
<script setup lang="ts"></script>

View File

@ -0,0 +1,12 @@
import FormNodeVue from './index.vue'
import { AppNode, AppNodeModel } from '@/workflow/common/app-node'
class FormNode extends AppNode {
constructor(props: any) {
super(props, FormNodeVue)
}
}
export default {
type: 'form-node',
model: AppNodeModel,
view: FormNode
}

View File

@ -0,0 +1,192 @@
<template>
<NodeContainer :nodeModel="nodeModel">
<el-form
@submit.prevent
:model="form_data"
label-position="top"
require-asterisk-position="right"
label-width="auto"
ref="formNodeFormRef"
hide-required-asterisk
>
<el-form-item
label="表单输出内容"
prop="form_content_format"
:rules="{
required: true,
message: '请表单输出内容',
trigger: 'blur'
}"
>
<template #label>
<div class="flex align-center">
<div class="mr-4">
<span>表单输出内容<span class="danger">*</span></span>
</div>
<el-tooltip effect="dark" placement="right" popper-class="max-w-200">
<template #content>
`设置执行该节点输出的内容,{{ '{ from }' }}为表单的占位符。`
</template>
<AppIcon iconName="app-warning" class="app-warning-icon"></AppIcon>
</el-tooltip>
</div>
</template>
<MdEditorMagnify
title="表单输出内容"
v-model="form_data.form_content_format"
style="height: 150px"
@submitDialog="submitDialog"
/>
</el-form-item>
<el-form-item label="表单配置" @click.prevent>
<div class="flex-between mb-16">
<h5 class="lighter">{{ '接口传参' }}</h5>
<el-button link type="primary" @click="openAddFormCollect()">
<el-icon class="mr-4">
<Plus />
</el-icon>
添加
</el-button>
</div>
<el-table
v-if="form_data.form_field_list.length > 0"
:data="form_data.form_field_list"
class="mb-16"
>
<el-table-column prop="field" label="参数" />
<el-table-column prop="label" label="显示名称">
<template #default="{ row }">
<span v-if="row.label && row.label.input_type === 'TooltipLabel'">{{
row.label.label
}}</span>
<span v-else>{{ row.label }}</span>
</template>
</el-table-column>
<el-table-column label="组件类型" width="110px">
<template #default="{ row }">
<el-tag type="info" class="info-tag">{{
input_type_list.find((item) => item.value === row.input_type)?.label
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="default_value" label="默认值" />
<el-table-column label="必填">
<template #default="{ row }">
<div @click.stop>
<el-switch disabled size="small" v-model="row.is_required" />
</div>
</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="openEditFormCollect(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(row)">
<el-icon>
<Delete />
</el-icon>
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</el-form-item>
</el-form>
<AddFormCollect ref="addFormCollectRef" :addFormField="addFormField"></AddFormCollect>
<EditFormCollect ref="editFormCollectRef" :editFormField="editFormField"></EditFormCollect>
</NodeContainer>
</template>
<script setup lang="ts">
import NodeContainer from '@/workflow/common/NodeContainer.vue'
import AddFormCollect from '@/workflow/common/AddFormCollect.vue'
import EditFormCollect from '@/workflow/common/EditFormCollect.vue'
import { type FormInstance } from 'element-plus'
import { ref, onMounted, computed } from 'vue'
import { input_type_list } from '@/components/dynamics-form/constructor/data'
import { MsgError } from '@/utils/message'
import { set } from 'lodash'
const props = defineProps<{ nodeModel: any }>()
const formNodeFormRef = ref<FormInstance>()
const editFormField = (form_field_data: any, field_index: number) => {
form_data.value.form_field_list = form_data.value.form_field_list.map(
(item: any, index: number) => {
if (field_index === index) {
return form_field_data
}
return item
}
)
sync_form_field_list()
}
const addFormField = (form_field_data: any) => {
if (form_data.value.form_field_list.some((field: any) => field.field === form_field_data.field)) {
MsgError('参数已存在:' + form_field_data.field)
return
}
form_data.value.form_field_list = [...form_data.value.form_field_list, form_field_data]
sync_form_field_list()
}
const sync_form_field_list = () => {
const fields = [
{
label: '表单全部内容',
value: 'form_data'
},
...form_data.value.form_field_list.map((item: any) => ({
value: item.field,
label: typeof item.label == 'string' ? item.label : item.label.label
}))
]
set(props.nodeModel.properties.config, 'fields', fields)
}
const addFormCollectRef = ref<InstanceType<typeof AddFormCollect>>()
const editFormCollectRef = ref<InstanceType<typeof EditFormCollect>>()
const openAddFormCollect = () => {
addFormCollectRef.value?.open()
}
const openEditFormCollect = (form_field_data: any, index: number) => {
editFormCollectRef.value?.open(form_field_data, index)
}
const deleteField = (form_field_data: any) => {
form_data.value.form_field_list = form_data.value.form_field_list.filter(
(field: any) => field.field !== form_field_data.field
)
}
const form = ref<any>({
is_result: true,
form_content_format: `你好,请先填写下面表单内容:
{{form}}
填写后请点击提交按钮进行提交`,
form_field_list: []
})
const form_data = computed({
get: () => {
if (props.nodeModel.properties.node_data) {
return props.nodeModel.properties.node_data
} else {
set(props.nodeModel.properties, 'node_data', form.value)
}
return props.nodeModel.properties.node_data
},
set: (value) => {
set(props.nodeModel.properties, 'node_data', value)
}
})
const validate = () => {
return formNodeFormRef.value?.validate()
}
function submitDialog(val: string) {
set(props.nodeModel.properties.node_data, 'form_content_format', val)
}
onMounted(() => {
set(props.nodeModel, 'validate', validate)
sync_form_field_list()
})
</script>
<style lang="scss" scoped></style>