第一次提交
This commit is contained in:
194
frontend/src/views/data-process/create/DatasetSplitEditor.vue
Normal file
194
frontend/src/views/data-process/create/DatasetSplitEditor.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DatasetSplitOptions } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: DatasetSplitOptions
|
||||
ariaLabelPrefix?: string
|
||||
}>(), {
|
||||
ariaLabelPrefix: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DatasetSplitOptions]
|
||||
}>()
|
||||
|
||||
const splitTotal = computed(() => {
|
||||
const { train, validation, test } = props.modelValue
|
||||
return train + validation + test
|
||||
})
|
||||
|
||||
function updateField(field: keyof DatasetSplitOptions, value: number | undefined) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
[field]: Math.min(100, Math.max(0, Number(value) || 0)),
|
||||
})
|
||||
}
|
||||
|
||||
function ariaLabel(label: string) {
|
||||
return `${props.ariaLabelPrefix}${label}比例`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="generation-option-row dataset-split-row">
|
||||
<div class="generation-option-copy">
|
||||
<strong>数据集划分</strong>
|
||||
<small>按比例将生成后的问答对随机划分为训练集、验证集和测试集</small>
|
||||
</div>
|
||||
<div class="dataset-split-config">
|
||||
<div class="dataset-split-grid">
|
||||
<label class="dataset-split-field">
|
||||
<span>训练集</span>
|
||||
<span class="dataset-split-input">
|
||||
<el-input-number
|
||||
:model-value="modelValue.train"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
:aria-label="ariaLabel('训练集')"
|
||||
@update:model-value="updateField('train', $event)"
|
||||
/>
|
||||
<span>%</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="dataset-split-field">
|
||||
<span>验证集</span>
|
||||
<span class="dataset-split-input">
|
||||
<el-input-number
|
||||
:model-value="modelValue.validation"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
:aria-label="ariaLabel('验证集')"
|
||||
@update:model-value="updateField('validation', $event)"
|
||||
/>
|
||||
<span>%</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="dataset-split-field">
|
||||
<span>测试集</span>
|
||||
<span class="dataset-split-input">
|
||||
<el-input-number
|
||||
:model-value="modelValue.test"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
:aria-label="ariaLabel('测试集')"
|
||||
@update:model-value="updateField('test', $event)"
|
||||
/>
|
||||
<span>%</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dataset-split-summary" :class="{ 'is-invalid': splitTotal !== 100 }">
|
||||
<span>总计 {{ splitTotal }}%</span>
|
||||
<span v-if="splitTotal !== 100" role="alert">
|
||||
训练集、验证集和测试集比例总和必须为 100%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.generation-option-row {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 12px 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.generation-option-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.dataset-split-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dataset-split-config {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
flex: 0 0 460px;
|
||||
}
|
||||
|
||||
.dataset-split-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dataset-split-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: #5f6878;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dataset-split-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.dataset-split-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #2ca66a;
|
||||
font-size: 12px;
|
||||
|
||||
&.is-invalid {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dataset-split-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dataset-split-config {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.dataset-split-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dataset-split-summary {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,382 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { ModelItem } from '@/types'
|
||||
import type { GenerationControlOptions } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
options: GenerationControlOptions
|
||||
models?: ModelItem[]
|
||||
section: 'model' | 'quality'
|
||||
validationMessage?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:options': [value: GenerationControlOptions]
|
||||
}>()
|
||||
|
||||
function updateField<K extends keyof GenerationControlOptions>(
|
||||
field: K,
|
||||
value: GenerationControlOptions[K],
|
||||
) {
|
||||
emit('update:options', { ...props.options, [field]: value })
|
||||
}
|
||||
|
||||
function updateQualityRules(value: Array<string | number>) {
|
||||
const rules = value.filter((item): item is string => typeof item === 'string')
|
||||
emit('update:options', {
|
||||
...props.options,
|
||||
filterLowQuality: rules.includes('low_quality'),
|
||||
filterShortContent: rules.includes('short_content'),
|
||||
})
|
||||
}
|
||||
|
||||
const selectedQualityRules = () => [
|
||||
props.options.filterLowQuality ? 'low_quality' : '',
|
||||
props.options.filterShortContent ? 'short_content' : '',
|
||||
].filter(Boolean)
|
||||
|
||||
function sectionValidationMessage() {
|
||||
if (!props.validationMessage) return ''
|
||||
const isModelMessage = props.validationMessage.includes('数据生成模型')
|
||||
if (props.section === 'model') return isModelMessage ? props.validationMessage : ''
|
||||
return isModelMessage ? '' : props.validationMessage
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="section === 'model'" class="generation-config-group model-config-group">
|
||||
<div class="model-field">
|
||||
<div class="field-copy">
|
||||
<strong>数据生成模型 <em>*</em></strong>
|
||||
<small>选择负责生成问答、指令或结构化内容的大模型</small>
|
||||
</div>
|
||||
<el-select
|
||||
class="model-select"
|
||||
:model-value="options.generationModelId"
|
||||
filterable
|
||||
placeholder="请选择用于生成数据的模型"
|
||||
aria-label="数据生成模型"
|
||||
@update:model-value="updateField('generationModelId', $event)"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in models || []"
|
||||
:key="model.id"
|
||||
:label="model.name"
|
||||
:value="model.id"
|
||||
>
|
||||
<div class="model-option">
|
||||
<span>{{ model.name }}</span>
|
||||
<small>{{ model.model_source === 'api' ? '在线模型' : '本地模型' }}</small>
|
||||
</div>
|
||||
</el-option>
|
||||
<template #empty>
|
||||
<div class="model-empty">暂无可用的大模型,请先在模型管理中添加</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="model-field">
|
||||
<div class="field-copy">
|
||||
<strong>默认提示语</strong>
|
||||
<small>用于约束生成内容的格式、语气和完整性,可按任务需要修改</small>
|
||||
</div>
|
||||
<el-input
|
||||
class="prompt-input"
|
||||
:model-value="options.generationPrompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
resize="vertical"
|
||||
placeholder="请输入模型生成内容时需要遵循的要求"
|
||||
aria-label="模型默认提示语"
|
||||
@update:model-value="updateField('generationPrompt', $event)"
|
||||
/>
|
||||
<div class="prompt-variables-hint">
|
||||
提示:可在文本中通过 <code v-text="'{{ content }}'" /> 引用当前正在处理的数据内容。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="advanced-settings-grid">
|
||||
<label class="config-field">
|
||||
<span class="config-field-label">生成温度 (Temperature)</span>
|
||||
<el-slider
|
||||
:model-value="options.temperature"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
show-input
|
||||
@update:model-value="updateField('temperature', Number($event) || 0)"
|
||||
/>
|
||||
<small>较低值输出更确定、保守,较高值输出更多样、有创造性</small>
|
||||
</label>
|
||||
|
||||
<label class="config-field">
|
||||
<span class="config-field-label">最大输出长度 (Max Tokens)</span>
|
||||
<span class="unit-input">
|
||||
<el-input-number
|
||||
:model-value="options.maxTokens"
|
||||
:min="100"
|
||||
:max="8192"
|
||||
:step="256"
|
||||
controls-position="right"
|
||||
@update:model-value="updateField('maxTokens', Number($event) || 1024)"
|
||||
/>
|
||||
<span>Token</span>
|
||||
</span>
|
||||
<small>限制单次生成的最大内容长度,防止过度发散</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="json-mode-row">
|
||||
<div class="field-copy">
|
||||
<strong>强制 JSON 格式输出</strong>
|
||||
<small>要求大模型返回纯 JSON 对象,有助于提高下游结构化提取的稳定性</small>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="options.jsonMode"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
@update:model-value="updateField('jsonMode', Boolean($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="sectionValidationMessage()" class="generation-validation-message" role="alert">
|
||||
{{ sectionValidationMessage() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="generation-config-group quality-config-group">
|
||||
<div class="quality-switch-row">
|
||||
<div class="config-group-heading">
|
||||
<div>
|
||||
<h4>质量筛选</h4>
|
||||
<p>生成后自动过滤不符合要求的内容,不影响源数据预处理</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="options.qualityFilterEnabled"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
aria-label="质量筛选"
|
||||
@update:model-value="updateField('qualityFilterEnabled', Boolean($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="options.qualityFilterEnabled" class="quality-rule-panel">
|
||||
<el-checkbox-group
|
||||
:model-value="selectedQualityRules()"
|
||||
class="quality-rule-list"
|
||||
@update:model-value="updateQualityRules"
|
||||
>
|
||||
<el-checkbox value="low_quality">
|
||||
<span class="rule-copy">
|
||||
<strong>过滤低质量内容</strong>
|
||||
<small>按相关性、完整性和可读性过滤明显不合格的生成结果</small>
|
||||
</span>
|
||||
</el-checkbox>
|
||||
<el-checkbox value="short_content">
|
||||
<span class="rule-copy">
|
||||
<strong>过滤过短内容</strong>
|
||||
<small>过滤正文长度低于指定字数的生成结果</small>
|
||||
</span>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
|
||||
<label v-if="options.filterShortContent" class="minimum-length-field">
|
||||
<span>最少字数</span>
|
||||
<span class="minimum-length-input">
|
||||
<el-input-number
|
||||
:model-value="options.minOutputLength"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="5"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
aria-label="生成内容最少字数"
|
||||
@update:model-value="updateField('minOutputLength', Number($event) || 1)"
|
||||
/>
|
||||
<span>字</span>
|
||||
</span>
|
||||
<small>少于该字数的生成结果将被过滤</small>
|
||||
</label>
|
||||
|
||||
<p v-if="!options.filterLowQuality && !options.filterShortContent" class="quality-rule-warning" role="alert">
|
||||
请至少选择一项质量筛选规则
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="sectionValidationMessage()" class="generation-validation-message" role="alert">
|
||||
{{ sectionValidationMessage() }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.generation-config-group {
|
||||
padding: 14px;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.config-group-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
h4 { margin: 0; color: #344054; font-size: 13px; font-weight: 600; }
|
||||
p { margin: 4px 0 0; color: #8a93a3; font-size: 12px; line-height: 1.5; }
|
||||
}
|
||||
|
||||
.model-config-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.model-config-group .advanced-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.model-config-group .config-field {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.model-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
em { color: var(--el-color-danger); font-style: normal; }
|
||||
}
|
||||
|
||||
.model-select { width: 100%; }
|
||||
.prompt-input { width: 100%; }
|
||||
.model-option { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.model-option small { color: #98a2b3; font-size: 11px; }
|
||||
.model-empty { padding: 12px; color: #8a93a3; font-size: 12px; text-align: center; }
|
||||
|
||||
.prompt-variables-hint {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
code {
|
||||
padding: 2px 4px;
|
||||
color: #5b50f2;
|
||||
background: #f0f0ff;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
color: #344054;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 8px;
|
||||
|
||||
> small {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:deep(.el-slider) {
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.config-field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unit-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #7b8495;
|
||||
font-size: 12px;
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.json-mode-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.quality-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.quality-rule-panel { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e7eaf0; }
|
||||
.quality-rule-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.quality-rule-list :deep(.el-checkbox) {
|
||||
min-height: 62px; height: auto; margin-right: 0; padding: 10px 12px;
|
||||
align-items: flex-start; border: 1px solid #e2e5ec; border-radius: 7px; background: #fff;
|
||||
}
|
||||
.quality-rule-list :deep(.el-checkbox__input) { margin-top: 3px; }
|
||||
.quality-rule-list :deep(.el-checkbox__label) { min-width: 0; padding-left: 9px; white-space: normal; }
|
||||
.rule-copy { display: grid; gap: 3px; }
|
||||
.rule-copy strong { color: #344054; font-size: 12px; font-weight: 600; }
|
||||
.rule-copy small { color: #8a93a3; font-size: 11px; line-height: 1.5; }
|
||||
|
||||
.minimum-length-field {
|
||||
margin-top: 12px; display: grid; grid-template-columns: 110px 220px minmax(0, 1fr);
|
||||
align-items: center; gap: 12px; color: #475467; font-size: 12px;
|
||||
}
|
||||
.minimum-length-input { display: flex; align-items: center; gap: 8px; }
|
||||
.minimum-length-input :deep(.el-input-number) { width: 180px; }
|
||||
.minimum-length-field > small { color: #8a93a3; }
|
||||
.quality-rule-warning { margin: 10px 0 0; color: #dc2626; font-size: 12px; }
|
||||
.generation-validation-message { margin: 10px 0 0; color: #dc2626; font-size: 12px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.advanced-settings-grid { grid-template-columns: 1fr; }
|
||||
.quality-rule-list { grid-template-columns: 1fr; }
|
||||
.minimum-length-field { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
215
frontend/src/views/data-process/create/GenerationStep.vue
Normal file
215
frontend/src/views/data-process/create/GenerationStep.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import type { GenerationState, ProcessType } from './types'
|
||||
|
||||
defineProps<{
|
||||
taskName: string
|
||||
processType: ProcessType
|
||||
fileName: string
|
||||
previewCount: number
|
||||
modifiedCount: number
|
||||
generation: GenerationState
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
stop: []
|
||||
retry: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="generation-step">
|
||||
<div class="generation-layout">
|
||||
<div class="summary-panel">
|
||||
<div class="panel-heading">
|
||||
<strong>任务摘要</strong>
|
||||
<span>已完成预览确认</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>任务名称</dt><dd>{{ taskName }}</dd></div>
|
||||
<div><dt>数据类型</dt><dd>{{ processType === 'unstructured' ? '非结构化数据' : processType === 'external' ? '外来数据源拉取' : '结构化数据' }}</dd></div>
|
||||
<div><dt>源文件</dt><dd>{{ fileName }}</dd></div>
|
||||
<div><dt>预览条目</dt><dd>{{ previewCount.toLocaleString() }} 条</dd></div>
|
||||
<div><dt>已修改</dt><dd>{{ modifiedCount.toLocaleString() }} 条</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="run-panel" :class="`is-${generation.status}`">
|
||||
<div class="run-icon">
|
||||
<i v-if="generation.status === 'success'" class="fa fa-check" />
|
||||
<i v-else-if="generation.status === 'failed'" class="fa fa-exclamation" />
|
||||
<i v-else-if="generation.status === 'running'" class="fa fa-cog fa-spin" />
|
||||
<i v-else class="fa fa-play" />
|
||||
</div>
|
||||
<h3>
|
||||
{{ generation.status === 'idle' ? '准备开始处理'
|
||||
: generation.status === 'running' ? '正在生成数据'
|
||||
: generation.status === 'success' ? '数据生成完成'
|
||||
: '生成已停止' }}
|
||||
</h3>
|
||||
<p>{{ generation.message }}</p>
|
||||
<el-progress
|
||||
v-if="generation.status !== 'idle'"
|
||||
:percentage="generation.progress"
|
||||
:stroke-width="10"
|
||||
:status="generation.status === 'success' ? 'success' : undefined"
|
||||
/>
|
||||
<div class="run-meta">
|
||||
<span>解析源数据</span>
|
||||
<span>应用预览修改</span>
|
||||
<span>生成标准结果</span>
|
||||
</div>
|
||||
<el-button v-if="generation.status === 'running'" plain type="warning" @click="emit('stop')">
|
||||
停止生成
|
||||
</el-button>
|
||||
<el-button v-if="generation.status === 'failed'" plain type="primary" @click="emit('retry')">
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.generation-step {
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
.generation-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.78fr) minmax(420px, 1.22fr);
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.summary-panel,
|
||||
.run-panel {
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 15px 17px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8ebf0;
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #2ca66a;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
padding: 8px 17px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #8a93a3;
|
||||
}
|
||||
|
||||
dd {
|
||||
max-width: 65%;
|
||||
overflow: hidden;
|
||||
color: #344054;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-panel {
|
||||
display: flex;
|
||||
min-height: 360px;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 34px 48px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
|
||||
h3 {
|
||||
margin: 18px 0 8px;
|
||||
color: #2e3646;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
min-height: 22px;
|
||||
margin: 0 0 24px;
|
||||
color: #7b8495;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-progress) {
|
||||
width: 100%;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.run-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
color: #5b50f2;
|
||||
font-size: 22px;
|
||||
background: #f0efff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.run-panel.is-success .run-icon {
|
||||
color: #2ca66a;
|
||||
background: #eaf8f1;
|
||||
}
|
||||
|
||||
.run-panel.is-failed .run-icon {
|
||||
color: #d97706;
|
||||
background: #fff7e8;
|
||||
}
|
||||
|
||||
.run-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
color: #98a2b3;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.generation-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ModelItem } from '@/types'
|
||||
import type { GenerationControlOptions } from './types'
|
||||
import GenerationOptionsPanel from './GenerationOptionsPanel.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
options: GenerationControlOptions
|
||||
models: ModelItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:options': [value: GenerationControlOptions]
|
||||
}>()
|
||||
|
||||
const validationAttempted = ref(false)
|
||||
const modelValidationMessage = computed(() => (
|
||||
props.options.generationModelId === '' ? '请选择数据生成模型' : ''
|
||||
))
|
||||
|
||||
function validate() {
|
||||
validationAttempted.value = true
|
||||
if (!modelValidationMessage.value) return true
|
||||
ElMessage.error(modelValidationMessage.value)
|
||||
return false
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="model-selection-step" aria-labelledby="model-selection-title">
|
||||
<div class="form-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3 id="model-selection-title">大模型选择</h3>
|
||||
<p>选择本次数据生成使用的模型,并设置统一的输出要求</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-form-content">
|
||||
<GenerationOptionsPanel
|
||||
:options="options"
|
||||
:models="models"
|
||||
section="model"
|
||||
:validation-message="validationAttempted ? modelValidationMessage : ''"
|
||||
@update:options="emit('update:options', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.model-selection-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 0 0 26px;
|
||||
margin-bottom: 26px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 5px;
|
||||
color: #2f3747;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.model-form-content {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
585
frontend/src/views/data-process/create/PreviewCompareStep.vue
Normal file
585
frontend/src/views/data-process/create/PreviewCompareStep.vue
Normal file
@@ -0,0 +1,585 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { sourceLines } from './previewModel'
|
||||
import type { PreviewItem, ProcessType } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
sourceText: string
|
||||
items: PreviewItem[]
|
||||
selectedId: string | null
|
||||
processType: ProcessType
|
||||
fileName: string
|
||||
files: { id: string; name: string; count: number; modifiedCount: number }[]
|
||||
selectedFileId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedId': [value: string]
|
||||
'update:selectedFileId': [value: string]
|
||||
'update:item-content': [id: string, value: string]
|
||||
'remove:item': [id: string]
|
||||
}>()
|
||||
|
||||
const sourceViewerRef = ref<HTMLElement | null>(null)
|
||||
const search = ref('')
|
||||
const currentPage = ref(1)
|
||||
const PREVIEW_PAGE_SIZE = 6
|
||||
const editingItemId = ref<string | null>(null)
|
||||
const editorDraft = ref('')
|
||||
const lines = computed(() => sourceLines(props.sourceText))
|
||||
const selectedItem = computed(() => props.items.find((item) => item.id === props.selectedId) ?? props.items[0])
|
||||
const editingItem = computed(() => props.items.find((item) => item.id === editingItemId.value))
|
||||
|
||||
const filteredItems = computed(() => props.items.filter((item, index) => {
|
||||
const matchesSearch = !search.value.trim()
|
||||
|| item.editedContent.toLowerCase().includes(search.value.trim().toLowerCase())
|
||||
|| String(index + 1).includes(search.value.trim())
|
||||
return matchesSearch
|
||||
}))
|
||||
|
||||
const pagedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * PREVIEW_PAGE_SIZE
|
||||
return filteredItems.value.slice(start, start + PREVIEW_PAGE_SIZE)
|
||||
})
|
||||
|
||||
const selectedIndex = computed(() => props.items.findIndex((item) => item.id === selectedItem.value?.id))
|
||||
|
||||
function isLineHighlighted(lineStart: number, lineEnd: number) {
|
||||
const item = selectedItem.value
|
||||
if (!item || item.sourceStart == null || item.sourceEnd == null) return false
|
||||
return lineEnd >= item.sourceStart && lineStart <= item.sourceEnd
|
||||
}
|
||||
|
||||
function selectItem(id: string) {
|
||||
emit('update:selectedId', id)
|
||||
}
|
||||
|
||||
function openEditor(item: PreviewItem) {
|
||||
selectItem(item.id)
|
||||
editingItemId.value = item.id
|
||||
editorDraft.value = item.editedContent
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingItemId.value = null
|
||||
editorDraft.value = ''
|
||||
}
|
||||
|
||||
function saveEditor() {
|
||||
if (!editingItem.value) return
|
||||
emit('update:item-content', editingItem.value.id, editorDraft.value)
|
||||
closeEditor()
|
||||
}
|
||||
|
||||
function removeItem(item: PreviewItem) {
|
||||
selectItem(item.id)
|
||||
emit('remove:item', item.id)
|
||||
}
|
||||
|
||||
function handlePageChange() {
|
||||
closeEditor()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
currentPage.value = 1
|
||||
closeEditor()
|
||||
})
|
||||
|
||||
watch(() => props.selectedFileId, closeEditor)
|
||||
|
||||
watch(selectedItem, async (item) => {
|
||||
if (!item) return
|
||||
const visibleIndex = filteredItems.value.findIndex((entry) => entry.id === item.id)
|
||||
if (visibleIndex >= 0) {
|
||||
currentPage.value = Math.floor(visibleIndex / PREVIEW_PAGE_SIZE) + 1
|
||||
}
|
||||
|
||||
if (item.sourceStart == null) return
|
||||
await nextTick()
|
||||
const target = sourceViewerRef.value?.querySelector<HTMLElement>(`[data-source-start="${item.sourceStart}"]`)
|
||||
?? sourceViewerRef.value?.querySelector<HTMLElement>('.source-line.is-highlighted')
|
||||
target?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}, { immediate: true })
|
||||
|
||||
function itemNumber(item: PreviewItem) {
|
||||
return props.items.findIndex((entry) => entry.id === item.id) + 1
|
||||
}
|
||||
|
||||
function lineRange(item: PreviewItem) {
|
||||
if (item.sourceStartLine == null || item.sourceEndLine == null) return '手动新增,无源文件定位'
|
||||
return item.sourceStartLine === item.sourceEndLine
|
||||
? `来源:第 ${item.sourceStartLine} 行`
|
||||
: `来源:第 ${item.sourceStartLine}–${item.sourceEndLine} 行`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="preview-step">
|
||||
<div class="preview-file-switcher">
|
||||
<div class="file-switcher-control">
|
||||
<span>当前文件</span>
|
||||
<el-select
|
||||
:model-value="selectedFileId"
|
||||
filterable
|
||||
placeholder="选择文件"
|
||||
aria-label="选择当前预览文件"
|
||||
@update:model-value="emit('update:selectedFileId', $event)"
|
||||
>
|
||||
<el-option
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
:label="file.name"
|
||||
:value="file.id"
|
||||
>
|
||||
<div class="file-option">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<span>{{ file.count.toLocaleString() }} {{ processType === 'unstructured' ? '个切片' : '条记录' }}</span>
|
||||
<em v-if="file.modifiedCount">{{ file.modifiedCount }} 处已修改</em>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<span class="file-switcher-summary">
|
||||
{{ files.length }} 个文件 · 当前文件 {{ items.length.toLocaleString() }} {{ processType === 'unstructured' ? '个切片' : '条记录' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-workspace">
|
||||
<div class="source-pane">
|
||||
<div class="pane-header">
|
||||
<div>
|
||||
<strong>源文件 · {{ fileName }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="sourceViewerRef" class="source-viewer" tabindex="0" aria-label="源文件内容">
|
||||
<div
|
||||
v-for="line in lines"
|
||||
:key="line.number"
|
||||
class="source-line"
|
||||
:class="{ 'is-highlighted': isLineHighlighted(line.start, line.end) }"
|
||||
:data-source-start="line.start"
|
||||
>
|
||||
<span class="line-number">{{ line.number }}</span>
|
||||
<span class="line-content">{{ line.content || ' ' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-pane">
|
||||
<div class="pane-header">
|
||||
<strong>{{ processType === 'unstructured' ? '切片内容' : '记录内容' }}</strong>
|
||||
<span>共 {{ items.length.toLocaleString() }} 条</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!editingItem">
|
||||
<div class="preview-toolbar">
|
||||
<el-input v-model="search" clearable placeholder="搜索编号或内容" size="small">
|
||||
<template #prefix><i class="fa fa-search" /></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="preview-list" aria-label="预览条目列表">
|
||||
<div
|
||||
v-for="item in pagedItems"
|
||||
:key="item.id"
|
||||
class="preview-item"
|
||||
:class="{ 'is-active': item.id === selectedItem?.id }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectItem(item.id)"
|
||||
@keydown.enter="selectItem(item.id)"
|
||||
@keydown.space.prevent="selectItem(item.id)"
|
||||
>
|
||||
<span class="item-name">{{ processType === 'unstructured' ? '切片' : '记录' }} #{{ String(itemNumber(item)).padStart(3, '0') }}</span>
|
||||
<span class="item-source">{{ lineRange(item) }}</span>
|
||||
<span class="item-actions">
|
||||
<el-button link aria-label="编辑切片" title="编辑" @click.stop="openEditor(item)">
|
||||
<i class="fa fa-pencil" />
|
||||
</el-button>
|
||||
<el-button link type="danger" aria-label="删除切片" title="删除" @click.stop="removeItem(item)">
|
||||
<i class="fa fa-trash-o" />
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!filteredItems.length" class="empty-result">没有符合条件的内容</div>
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
v-if="filteredItems.length > PREVIEW_PAGE_SIZE"
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="PREVIEW_PAGE_SIZE"
|
||||
:total="filteredItems.length"
|
||||
:pager-count="5"
|
||||
small
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
class="preview-pagination"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="preview-editor">
|
||||
<div class="editor-heading">
|
||||
<div>
|
||||
<strong>{{ processType === 'unstructured' ? '切片' : '记录' }} #{{ String(itemNumber(editingItem)).padStart(3, '0') }} 正文</strong>
|
||||
<small>{{ lineRange(editingItem) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="editorDraft"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
resize="none"
|
||||
/>
|
||||
<div class="editor-actions">
|
||||
<div>
|
||||
<el-button @click="closeEditor">取消</el-button>
|
||||
<el-button type="primary" @click="saveEditor">保存修改</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.preview-step {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-file-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 58px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.file-switcher-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
|
||||
> span {
|
||||
flex: none;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-select) {
|
||||
width: min(360px, 42vw);
|
||||
}
|
||||
}
|
||||
|
||||
.file-switcher-summary {
|
||||
color: #7d8798;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 440px;
|
||||
|
||||
strong,
|
||||
span,
|
||||
em {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #98a2b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
em {
|
||||
color: #5549dc;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 58fr) minmax(380px, 42fr);
|
||||
height: clamp(560px, calc(100vh - 370px), 720px);
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.source-pane,
|
||||
.preview-pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.source-pane {
|
||||
border-right: 1px solid #e5e8ee;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 52px;
|
||||
padding: 0 15px;
|
||||
color: #313949;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8ebf0;
|
||||
font-size: 13px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
> span {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.source-viewer {
|
||||
flex: 1;
|
||||
height: 538px;
|
||||
padding: 12px 0 24px;
|
||||
overflow: auto;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.source-line {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
min-height: 29px;
|
||||
color: #424b5d;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease;
|
||||
|
||||
&.is-highlighted {
|
||||
background: #eeedff;
|
||||
border-left-color: #5b50f2;
|
||||
}
|
||||
}
|
||||
|
||||
.line-number {
|
||||
padding-right: 11px;
|
||||
color: #a0a7b4;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
min-width: 0;
|
||||
padding: 2px 14px 2px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview-toolbar {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
border-bottom: 1px solid #e8ebf0;
|
||||
}
|
||||
|
||||
.preview-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-height: 38px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #e8ebf0;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
display: grid;
|
||||
grid-template-columns: 94px minmax(130px, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
color: #6b7382;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid transparent;
|
||||
border-bottom-color: #edf0f5;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #fafaff;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: #3f36c8;
|
||||
background: #f6f5ff;
|
||||
border-color: #5b50f2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #344054;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.item-source,
|
||||
.item-actions {
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
:deep(.el-button) {
|
||||
width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-result {
|
||||
padding: 34px 16px;
|
||||
color: #98a2b3;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-editor {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
padding: 13px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
strong,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-top: 4px;
|
||||
color: #98a2b3;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-editor :deep(.el-textarea__inner) {
|
||||
min-height: 260px !important;
|
||||
color: #3f4756;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.preview-workspace {
|
||||
grid-template-columns: minmax(0, 52fr) minmax(360px, 48fr);
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.preview-file-switcher {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-switcher-control {
|
||||
width: 100%;
|
||||
|
||||
:deep(.el-select) {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.file-switcher-summary {
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.preview-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.source-pane {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e5e8ee;
|
||||
}
|
||||
|
||||
.source-viewer {
|
||||
height: 320px;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
315
frontend/src/views/data-process/create/ResultEditorStep.vue
Normal file
315
frontend/src/views/data-process/create/ResultEditorStep.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ResultItem } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
items: ResultItem[]
|
||||
selectedId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedId': [value: string]
|
||||
'update:field': [id: string, field: 'instruction' | 'input' | 'output', value: string]
|
||||
'restore:item': [id: string]
|
||||
}>()
|
||||
|
||||
const search = ref('')
|
||||
const invalidOnly = ref(false)
|
||||
const selectedItem = computed(() => props.items.find((item) => item.id === props.selectedId) ?? props.items[0])
|
||||
const selectedIndex = computed(() => props.items.findIndex((item) => item.id === selectedItem.value?.id))
|
||||
|
||||
const filteredItems = computed(() => props.items.filter((item, index) => {
|
||||
const keyword = search.value.trim().toLowerCase()
|
||||
const matchesSearch = !keyword
|
||||
|| item.instruction.toLowerCase().includes(keyword)
|
||||
|| item.output.toLowerCase().includes(keyword)
|
||||
|| String(index + 1).includes(keyword)
|
||||
return matchesSearch && (!invalidOnly.value || item.status === 'invalid')
|
||||
}))
|
||||
|
||||
function selectRelative(offset: number) {
|
||||
if (!props.items.length) return
|
||||
const nextIndex = Math.min(Math.max(selectedIndex.value + offset, 0), props.items.length - 1)
|
||||
emit('update:selectedId', props.items[nextIndex].id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="result-step">
|
||||
<div class="result-workspace">
|
||||
<aside class="result-list-pane">
|
||||
<div class="pane-header"><strong>生成结果</strong><span>共 {{ items.length }} 条</span></div>
|
||||
<div class="result-toolbar">
|
||||
<el-input v-model="search" clearable size="small" placeholder="搜索结果">
|
||||
<template #prefix><i class="fa fa-search" /></template>
|
||||
</el-input>
|
||||
<el-checkbox v-model="invalidOnly">仅看错误</el-checkbox>
|
||||
</div>
|
||||
<div class="result-list">
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="result-item"
|
||||
:class="{ 'is-active': item.id === selectedItem?.id }"
|
||||
@click="emit('update:selectedId', item.id)"
|
||||
>
|
||||
<span class="result-index">#{{ String(items.findIndex((entry) => entry.id === item.id) + 1).padStart(3, '0') }}</span>
|
||||
<span class="result-copy">
|
||||
<strong>{{ item.instruction || '未填写指令' }}</strong>
|
||||
<small>{{ item.output || '未填写输出' }}</small>
|
||||
</span>
|
||||
<i class="fa" :class="item.status === 'invalid' ? 'fa-exclamation-circle is-error' : 'fa-check-circle is-valid'" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div v-if="selectedItem" class="result-editor-pane">
|
||||
<div class="pane-header">
|
||||
<div>
|
||||
<strong>结果 #{{ String(selectedIndex + 1).padStart(3, '0') }}</strong>
|
||||
<span v-if="selectedItem.status === 'modified'" class="modified-label">已修改</span>
|
||||
</div>
|
||||
<el-button link @click="emit('restore:item', selectedItem.id)"><i class="fa fa-undo" /> 恢复生成结果</el-button>
|
||||
</div>
|
||||
|
||||
<div class="field-editor">
|
||||
<label>Instruction <em>必填</em></label>
|
||||
<el-input
|
||||
:model-value="selectedItem.instruction"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
@update:model-value="emit('update:field', selectedItem.id, 'instruction', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-editor">
|
||||
<label>Input <span>选填</span></label>
|
||||
<el-input
|
||||
:model-value="selectedItem.input"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
@update:model-value="emit('update:field', selectedItem.id, 'input', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-editor">
|
||||
<label>Output <em>必填</em></label>
|
||||
<el-input
|
||||
:model-value="selectedItem.output"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
@update:model-value="emit('update:field', selectedItem.id, 'output', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="selectedItem.error" class="validation-error">
|
||||
<i class="fa fa-exclamation-circle" /> {{ selectedItem.error }}
|
||||
</div>
|
||||
<div v-else class="validation-success">
|
||||
<i class="fa fa-check-circle" /> 字段校验通过
|
||||
</div>
|
||||
<div class="editor-pagination">
|
||||
<el-button :disabled="selectedIndex <= 0" @click="selectRelative(-1)">上一条</el-button>
|
||||
<span>{{ selectedIndex + 1 }} / {{ items.length }}</span>
|
||||
<el-button :disabled="selectedIndex >= items.length - 1" @click="selectRelative(1)">下一条</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.result-step {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.result-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 34fr) minmax(480px, 66fr);
|
||||
min-height: clamp(420px, calc(100vh - 500px), 590px);
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.result-list-pane {
|
||||
min-width: 0;
|
||||
border-right: 1px solid #e5e8ee;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 52px;
|
||||
padding: 0 15px;
|
||||
color: #344054;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8ebf0;
|
||||
font-size: 13px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
> span,
|
||||
.modified-label {
|
||||
color: #8a93a3;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
height: 476px;
|
||||
padding: 7px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
padding: 9px;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid transparent;
|
||||
border-bottom-color: #edf0f5;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: #f7f6ff;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: #5b50f2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-index {
|
||||
color: #667085;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.result-copy {
|
||||
min-width: 0;
|
||||
|
||||
strong,
|
||||
small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-top: 5px;
|
||||
color: #98a2b3;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.is-valid {
|
||||
color: #2ca66a;
|
||||
}
|
||||
|
||||
.is-error {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.result-editor-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-editor {
|
||||
padding: 13px 18px 0;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 7px;
|
||||
color: #344054;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
em {
|
||||
color: #e05252;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #98a2b3;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.validation-error,
|
||||
.validation-success {
|
||||
margin: 12px 18px 0;
|
||||
padding: 9px 11px;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.validation-error {
|
||||
color: #b45309;
|
||||
background: #fff7e8;
|
||||
}
|
||||
|
||||
.validation-success {
|
||||
color: #25895c;
|
||||
background: #edf9f3;
|
||||
}
|
||||
|
||||
.editor-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
|
||||
span {
|
||||
color: #8a93a3;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.result-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.result-list-pane {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #e5e8ee;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
558
frontend/src/views/data-process/create/SourceUploadStep.vue
Normal file
558
frontend/src/views/data-process/create/SourceUploadStep.vue
Normal file
@@ -0,0 +1,558 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
import type { ExternalDataSource, ProcessType, UploadedDataFile } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
processType: ProcessType
|
||||
uploadedFiles: UploadedDataFile[]
|
||||
externalSource: ExternalDataSource
|
||||
externalPulling: boolean
|
||||
externalConnected: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:externalSource': [value: ExternalDataSource]
|
||||
'file-change': [file: UploadFile]
|
||||
'remove-file': [uid: string | number]
|
||||
'use-sample': []
|
||||
'test-connection': []
|
||||
'pull-data': []
|
||||
}>()
|
||||
|
||||
const DATA_SOURCE_TYPES = [
|
||||
{ value: 'mysql', label: 'MySQL' },
|
||||
{ value: 'postgresql', label: 'PostgreSQL' },
|
||||
{ value: 'mongodb', label: 'MongoDB' },
|
||||
{ value: 'api', label: 'REST API' },
|
||||
]
|
||||
|
||||
const AUTH_MODES = [
|
||||
{ value: 'none', label: '免鉴权' },
|
||||
{ value: 'basic', label: '账号密码' },
|
||||
{ value: 'token', label: 'Token' },
|
||||
]
|
||||
|
||||
const FILE_PAGE_SIZE = 10
|
||||
const currentFilePage = ref(1)
|
||||
|
||||
const isExternal = computed(() => props.processType === 'external')
|
||||
|
||||
const uploadAccept = computed(() => props.processType === 'unstructured'
|
||||
? '.txt,.md,.pdf,.docx,.doc,.json,.jsonl'
|
||||
: '.json,.jsonl,.csv,.xlsx,.xls')
|
||||
|
||||
const pagedUploadedFiles = computed(() => {
|
||||
const start = (currentFilePage.value - 1) * FILE_PAGE_SIZE
|
||||
return props.uploadedFiles.slice(start, start + FILE_PAGE_SIZE)
|
||||
})
|
||||
|
||||
watch(() => props.uploadedFiles.length, (newLength, oldLength) => {
|
||||
const totalPages = Math.max(1, Math.ceil(newLength / FILE_PAGE_SIZE))
|
||||
if (newLength > oldLength) {
|
||||
currentFilePage.value = totalPages
|
||||
return
|
||||
}
|
||||
currentFilePage.value = Math.min(currentFilePage.value, totalPages)
|
||||
})
|
||||
|
||||
watch(() => props.processType, () => {
|
||||
currentFilePage.value = 1
|
||||
})
|
||||
|
||||
function updateExternalField<K extends keyof ExternalDataSource>(field: K, value: ExternalDataSource[K]) {
|
||||
emit('update:externalSource', { ...props.externalSource, [field]: value })
|
||||
}
|
||||
|
||||
function formatSize(size: number) {
|
||||
if (!size) return '0 KB'
|
||||
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(size / 1024).toFixed(1)} KB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="source-upload-step" aria-labelledby="source-upload-title">
|
||||
<div v-if="isExternal" class="form-section external-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3 id="source-upload-title">数据源配置</h3>
|
||||
<p>配置并验证外部数据源,拉取成功后可在下一步预览数据内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="external-form">
|
||||
<el-form label-position="top" class="external-grid">
|
||||
<el-form-item label="数据源类型">
|
||||
<el-select
|
||||
:model-value="externalSource.type"
|
||||
placeholder="请选择数据源类型"
|
||||
aria-label="数据源类型"
|
||||
@update:model-value="updateExternalField('type', $event)"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in DATA_SOURCE_TYPES"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址 / URL">
|
||||
<el-input
|
||||
:model-value="externalSource.url"
|
||||
placeholder="例如:mysql://host:3306/db 或 https://api.example.com/data"
|
||||
aria-label="数据源地址或 URL"
|
||||
@update:model-value="updateExternalField('url', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="鉴权方式">
|
||||
<el-select
|
||||
:model-value="externalSource.authMode"
|
||||
aria-label="鉴权方式"
|
||||
@update:model-value="updateExternalField('authMode', $event)"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in AUTH_MODES"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="externalSource.authMode === 'basic'" label="账号">
|
||||
<el-input
|
||||
:model-value="externalSource.username"
|
||||
autocomplete="username"
|
||||
placeholder="请输入账号"
|
||||
aria-label="数据源账号"
|
||||
@update:model-value="updateExternalField('username', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="externalSource.authMode === 'basic'" label="密码">
|
||||
<el-input
|
||||
:model-value="externalSource.password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
aria-label="数据源密码"
|
||||
@update:model-value="updateExternalField('password', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="externalSource.authMode === 'token'" label="Token">
|
||||
<el-input
|
||||
:model-value="externalSource.token"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="off"
|
||||
placeholder="请输入访问 Token"
|
||||
aria-label="数据源访问 Token"
|
||||
@update:model-value="updateExternalField('token', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="拉取条数">
|
||||
<el-input-number
|
||||
:model-value="externalSource.limit"
|
||||
:min="1"
|
||||
:max="100000"
|
||||
:step="100"
|
||||
controls-position="right"
|
||||
aria-label="数据拉取条数"
|
||||
@update:model-value="updateExternalField('limit', Number($event) || 0)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="external-actions">
|
||||
<el-button
|
||||
:loading="externalPulling && !externalConnected"
|
||||
:disabled="externalPulling"
|
||||
plain
|
||||
@click="emit('test-connection')"
|
||||
>
|
||||
测试连接
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="externalPulling"
|
||||
:disabled="externalPulling"
|
||||
@click="emit('pull-data')"
|
||||
>
|
||||
拉取数据
|
||||
</el-button>
|
||||
<span v-if="externalConnected" class="external-status is-connected" role="status">
|
||||
<i class="fa fa-check-circle" aria-hidden="true" /> 连接正常
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section v-if="uploadedFiles.length" class="uploaded-file-list" aria-label="已拉取数据列表">
|
||||
<div class="uploaded-file-list-header">
|
||||
<span>已拉取 {{ uploadedFiles.length }} 个数据集</span>
|
||||
</div>
|
||||
<div class="uploaded-file-items">
|
||||
<div v-for="file in pagedUploadedFiles" :key="file.uid" class="uploaded-file">
|
||||
<span class="file-icon"><i class="fa fa-cloud-download" aria-hidden="true" /></span>
|
||||
<div class="file-main">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<span>
|
||||
{{ formatSize(file.size) }}
|
||||
<template v-if="file.count"> · {{ file.count.toLocaleString() }} 条</template>
|
||||
</span>
|
||||
</div>
|
||||
<span class="file-status"><i class="fa fa-check-circle" aria-hidden="true" /> 拉取成功</span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:aria-label="`删除数据集 ${file.name}`"
|
||||
@click="emit('remove-file', file.uid)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-pagination
|
||||
v-if="uploadedFiles.length > FILE_PAGE_SIZE"
|
||||
v-model:current-page="currentFilePage"
|
||||
:page-size="FILE_PAGE_SIZE"
|
||||
:total="uploadedFiles.length"
|
||||
:pager-count="5"
|
||||
small
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
class="uploaded-file-pagination"
|
||||
aria-label="已拉取数据分页"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-section upload-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3 id="source-upload-title">源数据上传</h3>
|
||||
<p>上传后可在下一步检查内容和切分效果,支持同时添加多个文件</p>
|
||||
</div>
|
||||
<el-button v-if="uploadedFiles.length === 0" link type="primary" @click="emit('use-sample')">
|
||||
使用示例数据
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-upload
|
||||
v-if="uploadedFiles.length === 0"
|
||||
drag
|
||||
multiple
|
||||
:accept="uploadAccept"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="(file: UploadFile) => emit('file-change', file)"
|
||||
aria-label="选择或拖拽源数据文件"
|
||||
>
|
||||
<i class="fa fa-cloud-upload upload-icon" aria-hidden="true" />
|
||||
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择文件</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
{{ processType === 'unstructured'
|
||||
? '支持 TXT、Markdown、PDF、Word、JSON、JSONL,单文件不超过 200MB'
|
||||
: '支持 JSON、JSONL、CSV、Excel,单文件不超过 200MB' }}
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<section v-else class="uploaded-file-list" aria-label="已上传文件列表">
|
||||
<div class="uploaded-file-list-header">
|
||||
<span>已添加 {{ uploadedFiles.length }} 个文件</span>
|
||||
<div class="continue-upload">
|
||||
<el-upload
|
||||
multiple
|
||||
:accept="uploadAccept"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="(file: UploadFile) => emit('file-change', file)"
|
||||
aria-label="继续添加源数据文件"
|
||||
>
|
||||
<el-button size="small" type="primary">继续上传</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uploaded-file-items">
|
||||
<div v-for="file in pagedUploadedFiles" :key="file.uid" class="uploaded-file">
|
||||
<span class="file-icon"><i class="fa fa-file-text-o" aria-hidden="true" /></span>
|
||||
<div class="file-main">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<span>
|
||||
{{ formatSize(file.size) }}
|
||||
<template v-if="file.count"> · {{ file.count.toLocaleString() }} 条</template>
|
||||
</span>
|
||||
</div>
|
||||
<span class="file-status"><i class="fa fa-check-circle" aria-hidden="true" /> 校验通过</span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:aria-label="`删除文件 ${file.name}`"
|
||||
@click="emit('remove-file', file.uid)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-pagination
|
||||
v-if="uploadedFiles.length > FILE_PAGE_SIZE"
|
||||
v-model:current-page="currentFilePage"
|
||||
:page-size="FILE_PAGE_SIZE"
|
||||
:total="uploadedFiles.length"
|
||||
:pager-count="5"
|
||||
small
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
class="uploaded-file-pagination"
|
||||
aria-label="已上传文件分页"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.source-upload-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 5px;
|
||||
color: #2f3747;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.external-section {
|
||||
.external-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-select),
|
||||
:deep(.el-input),
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.external-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.external-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
&.is-connected {
|
||||
color: #2ca66a;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-section :deep(.el-upload) {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.upload-section :deep(.el-upload-dragger) {
|
||||
width: 100%;
|
||||
min-height: 154px;
|
||||
padding: 32px 20px;
|
||||
background: #fff;
|
||||
border-color: #dfe3ea;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: #fafaff;
|
||||
border-color: #8b82f4;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #5b50f2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
margin-bottom: 12px;
|
||||
color: #5b50f2;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.uploaded-file-list {
|
||||
margin-top: 20px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe3ea;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.uploaded-file-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
color: #5f6878;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.continue-upload {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.continue-upload :deep(.el-upload) {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.uploaded-file-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.uploaded-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #5b50f2;
|
||||
font-size: 14px;
|
||||
background: #f0efff;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.file-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
color: #273142;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.file-status {
|
||||
color: #2ca66a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.uploaded-file :deep(.el-button) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.external-section .external-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.uploaded-file {
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.file-status {
|
||||
flex: 0 1 auto;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.uploaded-file-pagination {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.section-title-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.uploaded-file-list-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.uploaded-file {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-main {
|
||||
min-width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.file-status {
|
||||
margin-left: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.upload-section :deep(.el-upload-dragger) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
GenerationControlOptions,
|
||||
PreprocessOption,
|
||||
StructuredProcessOptions,
|
||||
} from './types'
|
||||
import DatasetSplitEditor from './DatasetSplitEditor.vue'
|
||||
import GenerationOptionsPanel from './GenerationOptionsPanel.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
options: StructuredProcessOptions
|
||||
validationAttempted: boolean
|
||||
qualityValidationMessage: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:options': [value: StructuredProcessOptions]
|
||||
}>()
|
||||
|
||||
const PREPROCESS_OPTIONS: Array<{
|
||||
value: PreprocessOption
|
||||
label: string
|
||||
description: string
|
||||
}> = [
|
||||
{ value: 'clean_invalid', label: '清理无效数据', description: '处理空行、空列和残缺行' },
|
||||
{ value: 'detect_structure', label: '识别表格结构', description: '识别表头、多级表头和合并单元格' },
|
||||
{ value: 'deduplicate', label: '重复数据去重', description: '删除完全重复或关键字段重复的数据' },
|
||||
{ value: 'normalize_format', label: '数据格式标准化', description: '统一日期、数字、单位和枚举值格式' },
|
||||
{ value: 'filter_anomaly', label: '异常数据过滤', description: '过滤乱码、无效内容和异常记录' },
|
||||
{ value: 'desensitize', label: '敏感信息脱敏', description: '处理姓名、手机号、邮箱等敏感信息' },
|
||||
]
|
||||
|
||||
function updateField<K extends keyof StructuredProcessOptions>(
|
||||
field: K,
|
||||
value: StructuredProcessOptions[K],
|
||||
) {
|
||||
emit('update:options', { ...props.options, [field]: value })
|
||||
}
|
||||
|
||||
function updateGenerationOptions(value: GenerationControlOptions) {
|
||||
emit('update:options', { ...props.options, ...value })
|
||||
}
|
||||
|
||||
function updatePreprocessOptions(value: Array<string | number | boolean>) {
|
||||
const allowedValues = new Set(PREPROCESS_OPTIONS.map((option) => option.value))
|
||||
const preprocessOptions = value.filter(
|
||||
(option): option is PreprocessOption => typeof option === 'string' && allowedValues.has(option as PreprocessOption),
|
||||
)
|
||||
updateField('preprocessOptions', preprocessOptions)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-section structured-options-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3>预处理选项</h3>
|
||||
<p>选择在生成问答对之前需要执行的数据处理方式</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-checkbox-group
|
||||
:model-value="options.preprocessOptions"
|
||||
class="preprocess-option-grid"
|
||||
@update:model-value="updatePreprocessOptions"
|
||||
>
|
||||
<el-checkbox
|
||||
v-for="option in PREPROCESS_OPTIONS"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="preprocess-option"
|
||||
>
|
||||
<span class="preprocess-option-copy">
|
||||
<strong>{{ option.label }}</strong>
|
||||
<small>{{ option.description }}</small>
|
||||
</span>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
|
||||
<div class="form-section generation-options-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3>生成选项</h3>
|
||||
<p>配置每条结构化记录生成问答对的方式和数量</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="generation-option-list">
|
||||
<GenerationOptionsPanel
|
||||
:options="options"
|
||||
section="quality"
|
||||
:validation-message="validationAttempted ? qualityValidationMessage : ''"
|
||||
@update:options="updateGenerationOptions"
|
||||
/>
|
||||
|
||||
<div class="generation-option-row">
|
||||
<div class="generation-option-copy">
|
||||
<strong>每行生成数量</strong>
|
||||
<small>每行结构化数据生成的问答对数量</small>
|
||||
</div>
|
||||
<el-input-number
|
||||
:model-value="options.qaPairsPerRow"
|
||||
:min="1"
|
||||
:max="5"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
@update:model-value="updateField('qaPairsPerRow', Number($event) || 1)"
|
||||
/>
|
||||
</div>
|
||||
<DatasetSplitEditor
|
||||
:model-value="options.datasetSplit"
|
||||
@update:model-value="updateField('datasetSplit', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-section {
|
||||
padding: 0 0 26px;
|
||||
margin-bottom: 26px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
color: #2f3747;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.preprocess-option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preprocess-option {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
margin-right: 0;
|
||||
padding: 12px 14px;
|
||||
box-sizing: border-box;
|
||||
align-items: flex-start;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #b7b2f7;
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
background: #fafaff;
|
||||
border-color: #8b82f4;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input) {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
min-width: 0;
|
||||
padding-left: 10px;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.preprocess-option-copy,
|
||||
.generation-option-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.generation-option-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.generation-option-row {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 12px 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.preprocess-option-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.generation-option-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
353
frontend/src/views/data-process/create/TaskSetupStep.vue
Normal file
353
frontend/src/views/data-process/create/TaskSetupStep.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import type {
|
||||
GenerationControlOptions,
|
||||
ProcessType,
|
||||
StructuredProcessOptions,
|
||||
UnstructuredProcessOptions,
|
||||
} from './types'
|
||||
import StructuredOptionsPanel from './StructuredOptionsPanel.vue'
|
||||
import UnstructuredOptionsPanel from './UnstructuredOptionsPanel.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
description: string
|
||||
processType: ProcessType
|
||||
structuredOptions: StructuredProcessOptions
|
||||
unstructuredOptions: UnstructuredProcessOptions
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:description': [value: string]
|
||||
'update:processType': [value: ProcessType]
|
||||
'update:structuredOptions': [value: StructuredProcessOptions]
|
||||
'update:unstructuredOptions': [value: UnstructuredProcessOptions]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const unstructuredOptionsPanelRef = ref<InstanceType<typeof UnstructuredOptionsPanel>>()
|
||||
const validationAttempted = ref(false)
|
||||
const formModel = computed(() => ({
|
||||
name: props.name,
|
||||
processType: props.processType,
|
||||
}))
|
||||
|
||||
const splitTotal = computed(() => {
|
||||
const { train, validation, test } = props.structuredOptions.datasetSplit
|
||||
return train + validation + test
|
||||
})
|
||||
|
||||
const unstructuredSplitTotal = computed(() => {
|
||||
const { train, validation, test } = props.unstructuredOptions.datasetSplit
|
||||
return train + validation + test
|
||||
})
|
||||
|
||||
const activeGenerationOptions = computed<GenerationControlOptions | null>(() => {
|
||||
if (props.processType === 'structured') return props.structuredOptions
|
||||
if (props.processType === 'unstructured') return props.unstructuredOptions
|
||||
return null
|
||||
})
|
||||
|
||||
const qualityValidationMessage = computed(() => {
|
||||
const options = activeGenerationOptions.value
|
||||
if (!options) return ''
|
||||
if (options.qualityFilterEnabled && !options.filterLowQuality && !options.filterShortContent) {
|
||||
return '开启质量筛选后,请至少选择一项筛选规则'
|
||||
}
|
||||
if (options.qualityFilterEnabled && options.filterShortContent && options.minOutputLength < 1) {
|
||||
return '最少字数必须大于 0'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const chunkValidationMessage = computed(() => {
|
||||
if (props.unstructuredOptions.chunkOverlap >= props.unstructuredOptions.chunkSize) {
|
||||
return '重叠长度必须小于切片长度'
|
||||
}
|
||||
if (props.unstructuredOptions.minChunkSize > props.unstructuredOptions.chunkSize) {
|
||||
return '最小切片长度不能大于切片长度'
|
||||
}
|
||||
if (
|
||||
props.unstructuredOptions.chunkOverlap + props.unstructuredOptions.minChunkSize
|
||||
> props.unstructuredOptions.chunkSize
|
||||
) {
|
||||
return '重叠长度与最小切片长度之和不能大于切片长度'
|
||||
}
|
||||
if (props.unstructuredOptions.chunkMethod === 'custom' && !props.unstructuredOptions.customDelimiter.trim()) {
|
||||
return '请输入自定义分隔符'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入任务名称', trigger: 'blur' },
|
||||
{ max: 50, message: '任务名称不能超过 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
processType: [{ required: true, message: '请选择数据处理类型', trigger: 'change' }],
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
validationAttempted.value = true
|
||||
if (!formRef.value) return false
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
if (props.processType === 'structured' && splitTotal.value !== 100) {
|
||||
ElMessage.error('结构化数据处理:数据集划分比例总和必须为 100%')
|
||||
return false
|
||||
}
|
||||
if (props.processType === 'unstructured') {
|
||||
if (unstructuredSplitTotal.value !== 100) {
|
||||
ElMessage.error('非结构化数据处理:数据集划分比例总和必须为 100%')
|
||||
return false
|
||||
}
|
||||
if (chunkValidationMessage.value) {
|
||||
unstructuredOptionsPanelRef.value?.revealValidation()
|
||||
ElMessage.error(chunkValidationMessage.value)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (qualityValidationMessage.value) {
|
||||
ElMessage.error(qualityValidationMessage.value)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
ElMessage.error('请完善标红的必填项')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="task-setup-step">
|
||||
<el-form ref="formRef" :model="formModel" :rules="rules" label-position="top" scroll-to-error>
|
||||
<div class="form-section">
|
||||
<h3>基本信息</h3>
|
||||
<div class="basic-grid">
|
||||
<el-form-item label="任务名称" prop="name" required>
|
||||
<el-input
|
||||
:model-value="name"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
placeholder="例如:金融问答清洗任务"
|
||||
@update:model-value="emit('update:name', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="任务描述(选填)">
|
||||
<el-input
|
||||
:model-value="description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="简要说明本次数据处理目标"
|
||||
@update:model-value="emit('update:description', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3>处理类型</h3>
|
||||
<p>类型会影响下一步支持的数据源格式和后续预览方式</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item prop="processType" class="type-form-item">
|
||||
<div class="type-options">
|
||||
<button
|
||||
type="button"
|
||||
class="type-option"
|
||||
:class="{ 'is-active': processType === 'structured' }"
|
||||
@click="emit('update:processType', 'structured')"
|
||||
>
|
||||
<span class="type-icon"><i class="fa fa-table" /></span>
|
||||
<span>
|
||||
<strong>结构化数据</strong>
|
||||
<small>适用于 CSV、Excel、JSONL 等固定字段数据</small>
|
||||
</span>
|
||||
<i class="fa fa-check-circle selection-mark" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="type-option"
|
||||
:class="{ 'is-active': processType === 'unstructured' }"
|
||||
@click="emit('update:processType', 'unstructured')"
|
||||
>
|
||||
<span class="type-icon"><i class="fa fa-file-text-o" /></span>
|
||||
<span>
|
||||
<strong>非结构化数据</strong>
|
||||
<small>适用于文档、文本、问答等需要切分的数据</small>
|
||||
</span>
|
||||
<i class="fa fa-check-circle selection-mark" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="type-option"
|
||||
:class="{ 'is-active': processType === 'external' }"
|
||||
@click="emit('update:processType', 'external')"
|
||||
>
|
||||
<span class="type-icon"><i class="fa fa-cloud-download" /></span>
|
||||
<span>
|
||||
<strong>外来数据源拉取</strong>
|
||||
<small>适用于数据库、API 接口等需远程拉取的外部数据</small>
|
||||
</span>
|
||||
<i class="fa fa-check-circle selection-mark" />
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<StructuredOptionsPanel
|
||||
v-if="processType === 'structured'"
|
||||
:options="structuredOptions"
|
||||
:validation-attempted="validationAttempted"
|
||||
:quality-validation-message="qualityValidationMessage"
|
||||
@update:options="emit('update:structuredOptions', $event)"
|
||||
/>
|
||||
|
||||
<UnstructuredOptionsPanel
|
||||
v-if="processType === 'unstructured'"
|
||||
ref="unstructuredOptionsPanelRef"
|
||||
:options="unstructuredOptions"
|
||||
:validation-attempted="validationAttempted"
|
||||
:quality-validation-message="qualityValidationMessage"
|
||||
:chunk-validation-message="chunkValidationMessage"
|
||||
@update:options="emit('update:unstructuredOptions', $event)"
|
||||
/>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.task-setup-step {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 0 0 26px;
|
||||
margin-bottom: 26px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
color: #2f3747;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
}
|
||||
|
||||
.basic-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.type-form-item {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.type-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.type-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 96px;
|
||||
padding: 18px;
|
||||
color: #4b5563;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe3ea;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #a8a3ff;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: #fafaff;
|
||||
border-color: #5b50f2;
|
||||
box-shadow: 0 0 0 1px rgba(91, 80, 242, 0.08);
|
||||
}
|
||||
|
||||
strong,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-bottom: 6px;
|
||||
color: #262d3d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #7b8495;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #5b50f2;
|
||||
font-size: 18px;
|
||||
background: #f0efff;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.selection-mark {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
color: #5b50f2;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.type-option.is-active .selection-mark {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.type-options {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,512 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type {
|
||||
ChunkMethod,
|
||||
GenerationControlOptions,
|
||||
UnstructuredPreprocessOption,
|
||||
UnstructuredProcessOptions,
|
||||
} from './types'
|
||||
import DatasetSplitEditor from './DatasetSplitEditor.vue'
|
||||
import GenerationOptionsPanel from './GenerationOptionsPanel.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
options: UnstructuredProcessOptions
|
||||
validationAttempted: boolean
|
||||
qualityValidationMessage: string
|
||||
chunkValidationMessage: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:options': [value: UnstructuredProcessOptions]
|
||||
}>()
|
||||
|
||||
const SMART_PREPROCESS_OPTIONS: UnstructuredPreprocessOption[] = [
|
||||
'clean_invalid_content',
|
||||
'detect_document_structure',
|
||||
'merge_short_content',
|
||||
'filter_low_quality',
|
||||
'deduplicate_content',
|
||||
'preserve_context',
|
||||
]
|
||||
|
||||
const CHUNK_METHODS: Array<{ value: ChunkMethod; label: string }> = [
|
||||
{ value: 'semantic', label: '自动语义切分' },
|
||||
{ value: 'heading', label: '按标题和段落' },
|
||||
{ value: 'fixed', label: '按固定长度' },
|
||||
{ value: 'custom', label: '自定义分隔符' },
|
||||
]
|
||||
|
||||
const UNSTRUCTURED_NUMBER_LIMITS = {
|
||||
chunkSize: { min: 200, max: 2000 },
|
||||
chunkOverlap: { min: 0, max: 500 },
|
||||
minChunkSize: { min: 20, max: 500 },
|
||||
qaPairsPerChunk: { min: 1, max: 3 },
|
||||
} as const
|
||||
|
||||
type UnstructuredNumberField = keyof typeof UNSTRUCTURED_NUMBER_LIMITS
|
||||
|
||||
const smartPreprocessEnabled = computed(() => SMART_PREPROCESS_OPTIONS.every(
|
||||
(option) => props.options.preprocessOptions.includes(option),
|
||||
))
|
||||
|
||||
const desensitizeEnabled = computed(() => props.options.preprocessOptions.includes('desensitize'))
|
||||
|
||||
const preserveSpecialContentEnabled = computed(() => (
|
||||
props.options.preserveTables
|
||||
&& props.options.preserveCodeBlocks
|
||||
&& props.options.preserveLists
|
||||
))
|
||||
|
||||
function updateField<K extends keyof UnstructuredProcessOptions>(
|
||||
field: K,
|
||||
value: UnstructuredProcessOptions[K],
|
||||
) {
|
||||
emit('update:options', { ...props.options, [field]: value })
|
||||
}
|
||||
|
||||
function updateGenerationOptions(value: GenerationControlOptions) {
|
||||
emit('update:options', { ...props.options, ...value })
|
||||
}
|
||||
|
||||
function updateSmartPreprocess(value: string | number | boolean) {
|
||||
const enabled = Boolean(value)
|
||||
const remainingOptions = props.options.preprocessOptions.filter(
|
||||
(option) => !SMART_PREPROCESS_OPTIONS.includes(option),
|
||||
)
|
||||
updateField(
|
||||
'preprocessOptions',
|
||||
enabled ? [...SMART_PREPROCESS_OPTIONS, ...remainingOptions] : remainingOptions,
|
||||
)
|
||||
}
|
||||
|
||||
function updateDesensitize(value: string | number | boolean) {
|
||||
const preprocessOptions: UnstructuredPreprocessOption[] = props.options.preprocessOptions.filter(
|
||||
(option) => option !== 'desensitize',
|
||||
)
|
||||
if (Boolean(value)) preprocessOptions.push('desensitize')
|
||||
updateField('preprocessOptions', preprocessOptions)
|
||||
}
|
||||
|
||||
function updateSpecialContentProtection(value: string | number | boolean) {
|
||||
const enabled = Boolean(value)
|
||||
emit('update:options', {
|
||||
...props.options,
|
||||
preserveTables: enabled,
|
||||
preserveCodeBlocks: enabled,
|
||||
preserveLists: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
function updateUnstructuredNumber(field: UnstructuredNumberField, value: number | undefined) {
|
||||
const limits = UNSTRUCTURED_NUMBER_LIMITS[field]
|
||||
const parsedValue = Number(value)
|
||||
const nextValue = Number.isFinite(parsedValue) ? parsedValue : limits.min
|
||||
updateField(field, Math.min(limits.max, Math.max(limits.min, nextValue)))
|
||||
}
|
||||
|
||||
function revealValidation() {
|
||||
// Advanced settings are now flattened, no need to open toggle
|
||||
}
|
||||
|
||||
defineExpose({ revealValidation })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-section unstructured-options-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3>预处理选项</h3>
|
||||
<p>默认使用推荐策略,只需决定是否需要脱敏</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preprocess-option-grid">
|
||||
<label class="preprocess-option" :class="{ 'is-checked': smartPreprocessEnabled }">
|
||||
<el-checkbox
|
||||
:model-value="smartPreprocessEnabled"
|
||||
@update:model-value="updateSmartPreprocess"
|
||||
/>
|
||||
<span class="preprocess-option-copy">
|
||||
<strong>智能预处理</strong>
|
||||
<small>自动清理、解析、去重及保留上下文</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="preprocess-option" :class="{ 'is-checked': desensitizeEnabled }">
|
||||
<el-checkbox
|
||||
:model-value="desensitizeEnabled"
|
||||
@update:model-value="updateDesensitize"
|
||||
/>
|
||||
<span class="preprocess-option-copy">
|
||||
<strong>敏感信息脱敏</strong>
|
||||
<small>处理姓名、手机号等隐私信息</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="preprocess-option" :class="{ 'is-checked': preserveSpecialContentEnabled }">
|
||||
<el-checkbox
|
||||
:model-value="preserveSpecialContentEnabled"
|
||||
@update:model-value="updateSpecialContentProtection"
|
||||
/>
|
||||
<span class="preprocess-option-copy">
|
||||
<strong>保护表格、代码和列表</strong>
|
||||
<small>避免切分点破坏特殊内容块的完整性</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section chunk-options-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3>切分选项</h3>
|
||||
<p>以语义完整为优先,将长文档拆成可独立生成问答的内容块</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chunk-settings-grid is-basic">
|
||||
<label class="config-field">
|
||||
<span class="config-field-label">切分方式</span>
|
||||
<el-select
|
||||
:model-value="options.chunkMethod"
|
||||
aria-label="切分方式"
|
||||
@update:model-value="updateField('chunkMethod', $event)"
|
||||
>
|
||||
<el-option
|
||||
v-for="method in CHUNK_METHODS"
|
||||
:key="method.value"
|
||||
:label="method.label"
|
||||
:value="method.value"
|
||||
/>
|
||||
</el-select>
|
||||
<small>推荐使用自动语义切分,在长度限制内优先保留完整句段</small>
|
||||
</label>
|
||||
|
||||
<label class="config-field">
|
||||
<span class="config-field-label">切片长度</span>
|
||||
<span class="unit-input">
|
||||
<el-input-number
|
||||
:model-value="options.chunkSize"
|
||||
:min="200"
|
||||
:max="2000"
|
||||
:step="50"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
aria-label="切片长度"
|
||||
@update:model-value="updateUnstructuredNumber('chunkSize', $event)"
|
||||
/>
|
||||
<span>Token</span>
|
||||
</span>
|
||||
<small>单个切片的目标上限,默认 800 Token</small>
|
||||
</label>
|
||||
|
||||
<label class="config-field">
|
||||
<span class="config-field-label">重叠长度</span>
|
||||
<span class="unit-input">
|
||||
<el-input-number
|
||||
:model-value="options.chunkOverlap"
|
||||
:min="0"
|
||||
:max="500"
|
||||
:step="10"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
aria-label="重叠长度"
|
||||
@update:model-value="updateUnstructuredNumber('chunkOverlap', $event)"
|
||||
/>
|
||||
<span>Token</span>
|
||||
</span>
|
||||
<small>在相邻切片中最多重复保留的上下文,默认 100 Token</small>
|
||||
</label>
|
||||
<label class="config-field">
|
||||
<span class="config-field-label">最小切片长度</span>
|
||||
<span class="unit-input">
|
||||
<el-input-number
|
||||
:model-value="options.minChunkSize"
|
||||
:min="20"
|
||||
:max="500"
|
||||
:step="10"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
aria-label="最小切片长度"
|
||||
@update:model-value="updateUnstructuredNumber('minChunkSize', $event)"
|
||||
/>
|
||||
<span>Token</span>
|
||||
</span>
|
||||
<small>过短的尾部内容会尽量并入前一个切片</small>
|
||||
</label>
|
||||
|
||||
<label v-if="options.chunkMethod === 'custom'" class="config-field">
|
||||
<span class="config-field-label">自定义分隔符</span>
|
||||
<el-input
|
||||
:model-value="options.customDelimiter"
|
||||
maxlength="40"
|
||||
show-word-limit
|
||||
placeholder="例如:--- 或 ###"
|
||||
aria-label="自定义分隔符"
|
||||
@update:model-value="updateField('customDelimiter', $event)"
|
||||
/>
|
||||
<small>系统会优先在分隔符位置结束当前切片</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="chunk-estimation-note">
|
||||
Token 数为轻量估算值,实际长度以训练使用的模型分词器为准。
|
||||
</p>
|
||||
|
||||
<p v-if="chunkValidationMessage" class="option-validation-message" role="alert">
|
||||
{{ chunkValidationMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-section generation-options-section">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h3>生成选项</h3>
|
||||
<p>只保留会直接影响输出的核心设置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="generation-option-list">
|
||||
<GenerationOptionsPanel
|
||||
:options="options"
|
||||
section="quality"
|
||||
:validation-message="validationAttempted ? qualityValidationMessage : ''"
|
||||
@update:options="updateGenerationOptions"
|
||||
/>
|
||||
|
||||
|
||||
<div class="generation-option-row">
|
||||
<div class="generation-option-copy">
|
||||
<strong>每个切片生成数量</strong>
|
||||
<small>每个内容切片最多生成 3 个不同角度的问答对</small>
|
||||
</div>
|
||||
<el-input-number
|
||||
:model-value="options.qaPairsPerChunk"
|
||||
:min="1"
|
||||
:max="3"
|
||||
:step="1"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
aria-label="每个切片生成数量"
|
||||
@update:model-value="updateUnstructuredNumber('qaPairsPerChunk', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DatasetSplitEditor
|
||||
:model-value="options.datasetSplit"
|
||||
aria-label-prefix="非结构化"
|
||||
@update:model-value="updateField('datasetSplit', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-section {
|
||||
padding: 0 0 26px;
|
||||
margin-bottom: 26px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
color: #2f3747;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.generation-option-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preprocess-option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preprocess-option {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
margin-right: 0;
|
||||
padding: 12px 14px;
|
||||
box-sizing: border-box;
|
||||
align-items: flex-start;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #b7b2f7;
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
background: #fafaff;
|
||||
border-color: #8b82f4;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input) {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
min-width: 0;
|
||||
padding-left: 10px;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.preprocess-option-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.generation-option-row {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 12px 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.generation-option-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.chunk-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
color: #344054;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e5ec;
|
||||
border-radius: 8px;
|
||||
|
||||
> small {
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:deep(.el-select),
|
||||
:deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.config-field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.unit-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #7b8495;
|
||||
font-size: 12px;
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.option-validation-message {
|
||||
display: block;
|
||||
margin: 8px 0 0;
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chunk-estimation-note {
|
||||
margin: 9px 0 0;
|
||||
color: #8a93a3;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chunk-settings-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.generation-option-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.compact-option-list .generation-option-row {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.preprocess-option-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
211
frontend/src/views/data-process/create/data-process-create.scss
Normal file
211
frontend/src/views/data-process/create/data-process-create.scss
Normal file
@@ -0,0 +1,211 @@
|
||||
.create-wizard-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border: 1px solid #eef0f5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wizard-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-main-inner {
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wizard-steps-container {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.custom-wizard-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
margin: 0 16px;
|
||||
background-color: #e2e8f0;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.step-connector.is-active {
|
||||
background-color: #5146e5;
|
||||
}
|
||||
|
||||
.step-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
background-color: #fff;
|
||||
border: 2px solid #cbd5e1;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-item.is-active {
|
||||
.step-icon {
|
||||
color: #5146e5;
|
||||
background-color: #eef2ff;
|
||||
border-color: #5146e5;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.step-item.is-completed {
|
||||
.step-icon {
|
||||
color: #fff;
|
||||
background-color: #5146e5;
|
||||
border-color: #5146e5;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.wizard-footer {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
padding: 0 32px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.02);
|
||||
|
||||
.footer-left {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-primary-action {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.custom-wizard-steps {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.step-node {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-item.is-active .step-title {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.wizard-main {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.custom-wizard-steps {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.step-node {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
max-width: 72px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.wizard-footer {
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.step-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.wizard-primary-action {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { StructuredProcessOptions, UnstructuredProcessOptions } from './types'
|
||||
|
||||
export const DEFAULT_GENERATION_PROMPT = '你是一名专业的数据生成助手。请根据输入内容生成准确、完整、可直接用于模型训练的问答数据。仅输出符合目标格式的内容,答案应事实清晰、语言自然,不要添加分析过程、说明或无关内容。'
|
||||
|
||||
export function createDefaultStructuredOptions(): StructuredProcessOptions {
|
||||
return {
|
||||
preprocessOptions: ['clean_invalid', 'detect_structure', 'deduplicate', 'normalize_format'],
|
||||
semanticEnrichment: false,
|
||||
qaPairsPerRow: 1,
|
||||
datasetSplit: { train: 80, validation: 10, test: 10 },
|
||||
generationModelId: '',
|
||||
generationPrompt: DEFAULT_GENERATION_PROMPT,
|
||||
temperature: 0.7,
|
||||
maxTokens: 1024,
|
||||
jsonMode: false,
|
||||
qualityFilterEnabled: false,
|
||||
filterLowQuality: true,
|
||||
filterShortContent: true,
|
||||
minOutputLength: 20,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultUnstructuredOptions(): UnstructuredProcessOptions {
|
||||
return {
|
||||
preprocessOptions: [
|
||||
'clean_invalid_content',
|
||||
'detect_document_structure',
|
||||
'merge_short_content',
|
||||
'filter_low_quality',
|
||||
'deduplicate_content',
|
||||
'preserve_context',
|
||||
],
|
||||
chunkMethod: 'semantic',
|
||||
chunkSize: 800,
|
||||
chunkOverlap: 100,
|
||||
minChunkSize: 100,
|
||||
customDelimiter: '',
|
||||
preserveTables: true,
|
||||
preserveCodeBlocks: true,
|
||||
preserveLists: true,
|
||||
semanticEnrichment: false,
|
||||
qaPairsPerChunk: 1,
|
||||
datasetSplit: { train: 80, validation: 10, test: 10 },
|
||||
generationModelId: '',
|
||||
generationPrompt: DEFAULT_GENERATION_PROMPT,
|
||||
temperature: 0.7,
|
||||
maxTokens: 1024,
|
||||
jsonMode: false,
|
||||
qualityFilterEnabled: false,
|
||||
filterLowQuality: true,
|
||||
filterShortContent: true,
|
||||
minOutputLength: 20,
|
||||
}
|
||||
}
|
||||
540
frontend/src/views/data-process/create/previewModel.ts
Normal file
540
frontend/src/views/data-process/create/previewModel.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import type {
|
||||
PreviewItem,
|
||||
ProcessType,
|
||||
ResultItem,
|
||||
SourceLine,
|
||||
StructuredProcessOptions,
|
||||
UnstructuredProcessOptions,
|
||||
} from './types'
|
||||
|
||||
export const DEFAULT_SOURCE_TEXT = [
|
||||
'问:如何看待当前的通货膨胀风险?',
|
||||
'答:当前通胀水平总体可控,但仍需关注能源价格与供给扰动。',
|
||||
'问:美联储下一次议息会议何时召开?',
|
||||
'答:会议时间以美联储官方日历为准,市场会重点关注利率路径指引。',
|
||||
'问:人民币汇率未来走势如何?',
|
||||
'答:人民币汇率取决于中美利差、经济基本面与政策预期。',
|
||||
'问:银行理财产品收益率为何持续走低?',
|
||||
'答:主要与市场利率下行、资产端收益下降以及风险偏好变化有关。',
|
||||
'问:什么是复利?',
|
||||
'答:复利是指在计算利息时,将上一期利息加入本金,再计算下一期利息。',
|
||||
'问:如何评估股票的投资价值?',
|
||||
'答:评估股票投资价值可以从以下几个方面进行:',
|
||||
'1. 公司基本面:分析公司的财务状况、盈利能力、成长性等。',
|
||||
'2. 行业前景:考察公司所处行业的发展趋势和竞争格局。',
|
||||
'3. 估值水平:通过市盈率、市净率等指标判断估值是否合理。',
|
||||
'4. 财务健康:关注公司的负债情况、现金流状况等。',
|
||||
'5. 管理团队:评估管理层的能力和过往业绩。',
|
||||
'此外,还需要关注宏观经济环境、政策变化等因素对股票市场的影响。',
|
||||
'问:债券和股票的主要区别是什么?',
|
||||
'答:债券收益相对稳定但上行有限,股票波动更大且承担更高风险。',
|
||||
'问:什么是市盈率?',
|
||||
'答:市盈率是股票价格与每股收益的比值,常用于衡量估值水平。',
|
||||
'问:如何进行资产配置?',
|
||||
'答:应根据投资目标、风险承受能力和市场环境合理分配资产。',
|
||||
].join('\n')
|
||||
|
||||
export function sourceLines(sourceText: string): SourceLine[] {
|
||||
const rawLines = sourceText.split('\n')
|
||||
let cursor = 0
|
||||
|
||||
return rawLines.map((content, index) => {
|
||||
const start = cursor
|
||||
const end = start + content.length
|
||||
cursor = end + (index < rawLines.length - 1 ? 1 : 0)
|
||||
return { number: index + 1, content, start, end }
|
||||
})
|
||||
}
|
||||
|
||||
interface SourceRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface ProtectedRange extends SourceRange {
|
||||
kind: 'code' | 'table' | 'list'
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 800
|
||||
const DEFAULT_CHUNK_OVERLAP = 100
|
||||
const DEFAULT_MIN_CHUNK_SIZE = 100
|
||||
|
||||
function finiteInteger(value: number | undefined, fallback: number, min: number): number {
|
||||
return Number.isFinite(value) ? Math.max(min, Math.round(value as number)) : fallback
|
||||
}
|
||||
|
||||
function trimSourceRange(sourceText: string, start: number, end: number): SourceRange {
|
||||
let nextStart = Math.max(0, start)
|
||||
let nextEnd = Math.min(sourceText.length, end)
|
||||
|
||||
while (nextStart < nextEnd && /\s/.test(sourceText[nextStart])) nextStart += 1
|
||||
while (nextEnd > nextStart && /\s/.test(sourceText[nextEnd - 1])) nextEnd -= 1
|
||||
|
||||
return { start: nextStart, end: nextEnd }
|
||||
}
|
||||
|
||||
function normalizeDelimiter(delimiter: string | undefined): string {
|
||||
return (delimiter ?? '').replace(/\\n/g, '\n').replace(/\\t/g, '\t')
|
||||
}
|
||||
|
||||
function overlapsRange(line: SourceLine, range: SourceRange): boolean {
|
||||
return line.start < range.end && line.end > range.start
|
||||
}
|
||||
|
||||
function isLineProtected(line: SourceLine, ranges: SourceRange[]): boolean {
|
||||
return ranges.some((range) => overlapsRange(line, range))
|
||||
}
|
||||
|
||||
function detectCodeBlockRanges(sourceText: string, lines: SourceLine[]): ProtectedRange[] {
|
||||
const ranges: ProtectedRange[] = []
|
||||
let openFence: { start: number; marker: string; length: number } | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
const fence = line.content.match(/^\s*(`{3,}|~{3,})/)
|
||||
if (!fence) continue
|
||||
|
||||
const marker = fence[1][0]
|
||||
if (!openFence) {
|
||||
openFence = { start: line.start, marker, length: fence[1].length }
|
||||
continue
|
||||
}
|
||||
|
||||
if (marker === openFence.marker && fence[1].length >= openFence.length) {
|
||||
ranges.push({ start: openFence.start, end: line.end, kind: 'code' })
|
||||
openFence = null
|
||||
}
|
||||
}
|
||||
|
||||
if (openFence) ranges.push({ start: openFence.start, end: sourceText.length, kind: 'code' })
|
||||
return ranges
|
||||
}
|
||||
|
||||
function isTableSeparator(content: string): boolean {
|
||||
const normalized = content.trim().replace(/^\|/, '').replace(/\|$/, '')
|
||||
const cells = normalized.split('|').map((cell) => cell.trim())
|
||||
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))
|
||||
}
|
||||
|
||||
function detectTableRanges(lines: SourceLine[], codeRanges: SourceRange[]): ProtectedRange[] {
|
||||
const ranges: ProtectedRange[] = []
|
||||
|
||||
for (let index = 0; index < lines.length - 1; index += 1) {
|
||||
const header = lines[index]
|
||||
const separator = lines[index + 1]
|
||||
if (
|
||||
isLineProtected(header, codeRanges)
|
||||
|| isLineProtected(separator, codeRanges)
|
||||
|| !header.content.includes('|')
|
||||
|| !isTableSeparator(separator.content)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
let endIndex = index + 1
|
||||
while (
|
||||
endIndex + 1 < lines.length
|
||||
&& !isLineProtected(lines[endIndex + 1], codeRanges)
|
||||
&& lines[endIndex + 1].content.trim()
|
||||
&& lines[endIndex + 1].content.includes('|')
|
||||
) {
|
||||
endIndex += 1
|
||||
}
|
||||
|
||||
ranges.push({ start: header.start, end: lines[endIndex].end, kind: 'table' })
|
||||
index = endIndex
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
function isListItem(content: string): boolean {
|
||||
return /^\s*(?:[-+*]|\d+[.)])\s+\S/.test(content)
|
||||
}
|
||||
|
||||
function isListContinuation(content: string): boolean {
|
||||
return /^\s{2,}\S/.test(content)
|
||||
}
|
||||
|
||||
function detectListRanges(
|
||||
lines: SourceLine[],
|
||||
excludedRanges: SourceRange[],
|
||||
): ProtectedRange[] {
|
||||
const ranges: ProtectedRange[] = []
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (isLineProtected(lines[index], excludedRanges) || !isListItem(lines[index].content)) continue
|
||||
|
||||
let endIndex = index
|
||||
let itemCount = 1
|
||||
while (endIndex + 1 < lines.length && !isLineProtected(lines[endIndex + 1], excludedRanges)) {
|
||||
const nextContent = lines[endIndex + 1].content
|
||||
if (isListItem(nextContent)) {
|
||||
itemCount += 1
|
||||
endIndex += 1
|
||||
continue
|
||||
}
|
||||
if (isListContinuation(nextContent)) {
|
||||
endIndex += 1
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (itemCount >= 2) {
|
||||
ranges.push({ start: lines[index].start, end: lines[endIndex].end, kind: 'list' })
|
||||
index = endIndex
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
function mergeProtectedRanges(ranges: ProtectedRange[]): ProtectedRange[] {
|
||||
return ranges
|
||||
.sort((left, right) => left.start - right.start || left.end - right.end)
|
||||
.reduce<ProtectedRange[]>((merged, range) => {
|
||||
const previous = merged[merged.length - 1]
|
||||
if (previous && range.start < previous.end) {
|
||||
previous.end = Math.max(previous.end, range.end)
|
||||
return merged
|
||||
}
|
||||
merged.push({ ...range })
|
||||
return merged
|
||||
}, [])
|
||||
}
|
||||
|
||||
function protectedRangesForOptions(
|
||||
sourceText: string,
|
||||
options?: UnstructuredProcessOptions,
|
||||
): ProtectedRange[] {
|
||||
if (!options?.preserveCodeBlocks && !options?.preserveTables && !options?.preserveLists) return []
|
||||
|
||||
const lines = sourceLines(sourceText)
|
||||
const codeRanges = detectCodeBlockRanges(sourceText, lines)
|
||||
const tableRanges = detectTableRanges(lines, codeRanges)
|
||||
const listRanges = detectListRanges(lines, [...codeRanges, ...tableRanges])
|
||||
const enabledRanges = [
|
||||
...(options?.preserveCodeBlocks ? codeRanges : []),
|
||||
...(options?.preserveTables ? tableRanges : []),
|
||||
...(options?.preserveLists ? listRanges : []),
|
||||
]
|
||||
|
||||
return mergeProtectedRanges(enabledRanges)
|
||||
}
|
||||
|
||||
function protectedRangeContaining(
|
||||
ranges: ProtectedRange[],
|
||||
offset: number,
|
||||
): ProtectedRange | undefined {
|
||||
return ranges.find((range) => range.start < offset && offset < range.end)
|
||||
}
|
||||
|
||||
function normalizeChunkStart(
|
||||
sourceText: string,
|
||||
cursor: number,
|
||||
protectedRanges: ProtectedRange[],
|
||||
): number {
|
||||
let start = Math.max(0, Math.min(cursor, sourceText.length))
|
||||
const overlapBlock = protectedRangeContaining(protectedRanges, start)
|
||||
if (overlapBlock) start = overlapBlock.end
|
||||
|
||||
while (start < sourceText.length && /\s/.test(sourceText[start])) start += 1
|
||||
|
||||
// 去除块前空白时可能进入缩进代码块/列表;此时恢复到完整块起点。
|
||||
const blockAfterTrim = protectedRangeContaining(protectedRanges, start)
|
||||
if (blockAfterTrim) return cursor <= blockAfterTrim.start ? blockAfterTrim.start : blockAfterTrim.end
|
||||
|
||||
return start
|
||||
}
|
||||
|
||||
function protectChunkEnd(
|
||||
proposedEnd: number,
|
||||
start: number,
|
||||
minimumEnd: number,
|
||||
protectedRanges: ProtectedRange[],
|
||||
): number {
|
||||
const splitBlock = protectedRangeContaining(protectedRanges, proposedEnd)
|
||||
if (!splitBlock) return proposedEnd
|
||||
|
||||
// 优先在块前结束;块前不足最小切片长度时,将整个块收入当前切片。
|
||||
return splitBlock.start > start && splitBlock.start >= minimumEnd
|
||||
? splitBlock.start
|
||||
: splitBlock.end
|
||||
}
|
||||
|
||||
function restoreProtectedEdges(
|
||||
range: SourceRange,
|
||||
rawStart: number,
|
||||
rawEnd: number,
|
||||
protectedRanges: ProtectedRange[],
|
||||
): SourceRange {
|
||||
const nextRange = { ...range }
|
||||
const startBlock = protectedRangeContaining(protectedRanges, nextRange.start)
|
||||
if (startBlock && rawStart <= startBlock.start) nextRange.start = startBlock.start
|
||||
|
||||
const endBlock = protectedRangeContaining(protectedRanges, nextRange.end)
|
||||
if (endBlock && rawEnd >= endBlock.end) nextRange.end = endBlock.end
|
||||
return nextRange
|
||||
}
|
||||
|
||||
function lastBoundaryInRange(
|
||||
sourceText: string,
|
||||
idealEnd: number,
|
||||
minimumEnd: number,
|
||||
): number | null {
|
||||
const candidates: number[] = []
|
||||
const boundaryTokens = ['\n\n', '\n', '。', '!', '?', ';', '.', '!', '?', ';']
|
||||
|
||||
boundaryTokens.forEach((token) => {
|
||||
const tokenStart = sourceText.lastIndexOf(token, idealEnd - token.length)
|
||||
const boundary = tokenStart === -1 ? -1 : tokenStart + token.length
|
||||
if (boundary >= minimumEnd && boundary <= idealEnd) candidates.push(boundary)
|
||||
})
|
||||
|
||||
return candidates.length ? Math.max(...candidates) : null
|
||||
}
|
||||
|
||||
function lastHeadingBoundary(
|
||||
sourceText: string,
|
||||
start: number,
|
||||
idealEnd: number,
|
||||
minimumEnd: number,
|
||||
): number | null {
|
||||
const section = sourceText.slice(start, idealEnd)
|
||||
const headingPattern = /^(?:#{1,6}\s+|第[一二三四五六七八九十百]+[章节篇部分]|\d+(?:\.\d+)*[、.\s])/gm
|
||||
let boundary: number | null = null
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = headingPattern.exec(section))) {
|
||||
const absoluteStart = start + match.index
|
||||
if (absoluteStart >= minimumEnd) boundary = absoluteStart
|
||||
}
|
||||
|
||||
return boundary
|
||||
}
|
||||
|
||||
function resolveChunkEnd(
|
||||
sourceText: string,
|
||||
start: number,
|
||||
idealEnd: number,
|
||||
minimumEnd: number,
|
||||
options: UnstructuredProcessOptions | undefined,
|
||||
): number {
|
||||
const method = options?.chunkMethod ?? 'semantic'
|
||||
|
||||
if (method === 'fixed') return idealEnd
|
||||
|
||||
if (method === 'custom') {
|
||||
const delimiter = normalizeDelimiter(options?.customDelimiter)
|
||||
if (!delimiter) return idealEnd
|
||||
|
||||
const delimiterStart = sourceText.lastIndexOf(delimiter, idealEnd - delimiter.length)
|
||||
const boundary = delimiterStart === -1 ? -1 : delimiterStart + delimiter.length
|
||||
return boundary >= minimumEnd ? boundary : idealEnd
|
||||
}
|
||||
|
||||
if (method === 'heading') {
|
||||
const headingBoundary = lastHeadingBoundary(sourceText, start, idealEnd, minimumEnd)
|
||||
if (headingBoundary !== null) return headingBoundary
|
||||
}
|
||||
|
||||
return lastBoundaryInRange(sourceText, idealEnd, minimumEnd) ?? idealEnd
|
||||
}
|
||||
|
||||
function buildUnstructuredRanges(
|
||||
sourceText: string,
|
||||
options?: UnstructuredProcessOptions,
|
||||
): SourceRange[] {
|
||||
// 预览统一沿用“约 2 个字符 = 1 token”的轻量估算,避免引入分词器依赖。
|
||||
const targetCharacters = finiteInteger(options?.chunkSize, DEFAULT_CHUNK_SIZE, 1) * 2
|
||||
const minimumCharacters = Math.min(
|
||||
targetCharacters,
|
||||
finiteInteger(options?.minChunkSize, DEFAULT_MIN_CHUNK_SIZE, 1) * 2,
|
||||
)
|
||||
const requestedOverlap = finiteInteger(options?.chunkOverlap, DEFAULT_CHUNK_OVERLAP, 0) * 2
|
||||
const protectedRanges = protectedRangesForOptions(sourceText, options)
|
||||
const ranges: SourceRange[] = []
|
||||
let cursor = 0
|
||||
|
||||
while (cursor < sourceText.length) {
|
||||
const start = normalizeChunkStart(sourceText, cursor, protectedRanges)
|
||||
if (start >= sourceText.length) break
|
||||
|
||||
const idealEnd = Math.min(sourceText.length, start + targetCharacters)
|
||||
const minimumEnd = Math.min(idealEnd, start + minimumCharacters)
|
||||
let end = idealEnd === sourceText.length
|
||||
? idealEnd
|
||||
: resolveChunkEnd(sourceText, start, idealEnd, minimumEnd, options)
|
||||
end = protectChunkEnd(end, start, minimumEnd, protectedRanges)
|
||||
|
||||
// 所有自定义边界都必须向前推进;异常配置回退到固定长度切分。
|
||||
if (end <= start) end = Math.min(sourceText.length, start + targetCharacters)
|
||||
|
||||
let range = trimSourceRange(sourceText, start, end)
|
||||
range = restoreProtectedEdges(range, start, end, protectedRanges)
|
||||
if (end < sourceText.length && range.end - range.start < minimumCharacters) {
|
||||
range.end = Math.min(end, range.start + minimumCharacters)
|
||||
}
|
||||
if (range.end <= range.start) {
|
||||
cursor = Math.max(cursor + 1, end)
|
||||
continue
|
||||
}
|
||||
|
||||
const isLastRange = end >= sourceText.length
|
||||
if (isLastRange && range.end - range.start < minimumCharacters && ranges.length) {
|
||||
ranges[ranges.length - 1].end = range.end
|
||||
break
|
||||
}
|
||||
|
||||
ranges.push(range)
|
||||
if (isLastRange) break
|
||||
|
||||
// overlap 是允许的最大重叠量;按当前切片动态收缩,保证每轮至少推进最小切片长度。
|
||||
const maximumOverlap = Math.max(0, range.end - range.start - minimumCharacters)
|
||||
const actualOverlap = Math.min(requestedOverlap, maximumOverlap)
|
||||
const nextCursor = range.end - actualOverlap
|
||||
cursor = nextCursor > start ? nextCursor : range.end
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
function lineNumberAtOffset(lines: SourceLine[], offset: number): number | null {
|
||||
if (!lines.length) return null
|
||||
|
||||
let low = 0
|
||||
let high = lines.length - 1
|
||||
let result = 0
|
||||
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2)
|
||||
if (lines[middle].start <= offset) {
|
||||
result = middle
|
||||
low = middle + 1
|
||||
} else {
|
||||
high = middle - 1
|
||||
}
|
||||
}
|
||||
|
||||
return lines[result].number
|
||||
}
|
||||
|
||||
function previewItemFromRange(
|
||||
sourceText: string,
|
||||
lines: SourceLine[],
|
||||
range: SourceRange,
|
||||
sourceFileId: string,
|
||||
index: number,
|
||||
): PreviewItem {
|
||||
const content = sourceText.slice(range.start, range.end)
|
||||
|
||||
return {
|
||||
id: `preview-${sourceFileId}-${index + 1}`,
|
||||
sourceFileId,
|
||||
originalContent: content,
|
||||
editedContent: content,
|
||||
sourceStart: range.start,
|
||||
sourceEnd: range.end,
|
||||
sourceStartLine: lineNumberAtOffset(lines, range.start),
|
||||
sourceEndLine: lineNumberAtOffset(lines, Math.max(range.start, range.end - 1)),
|
||||
tokenCount: Math.max(1, Math.ceil(content.length / 2)),
|
||||
status: 'original',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPreviewItems(
|
||||
sourceText: string,
|
||||
processType: ProcessType,
|
||||
sourceFileId = 'default-source',
|
||||
unstructuredOptions?: UnstructuredProcessOptions,
|
||||
): PreviewItem[] {
|
||||
const lines = sourceLines(sourceText)
|
||||
|
||||
if (processType === 'unstructured') {
|
||||
return buildUnstructuredRanges(sourceText, unstructuredOptions).map((range, index) => (
|
||||
previewItemFromRange(sourceText, lines, range, sourceFileId, index)
|
||||
))
|
||||
}
|
||||
|
||||
const meaningfulLines = lines.filter((line) => line.content.trim())
|
||||
const groupSize = processType === 'structured' ? 1 : 3
|
||||
const items: PreviewItem[] = []
|
||||
|
||||
for (let index = 0; index < meaningfulLines.length; index += groupSize) {
|
||||
const group = meaningfulLines.slice(index, index + groupSize)
|
||||
if (!group.length) continue
|
||||
|
||||
const sourceStart = group[0].start
|
||||
const sourceEnd = group[group.length - 1].end
|
||||
const content = sourceText.slice(sourceStart, sourceEnd)
|
||||
|
||||
items.push({
|
||||
id: `preview-${sourceFileId}-${items.length + 1}`,
|
||||
sourceFileId,
|
||||
originalContent: content,
|
||||
editedContent: content,
|
||||
sourceStart,
|
||||
sourceEnd,
|
||||
sourceStartLine: group[0].number,
|
||||
sourceEndLine: group[group.length - 1].number,
|
||||
tokenCount: Math.max(1, Math.ceil(content.length / 2)),
|
||||
status: 'original',
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
const SEMANTIC_PREFIXES = [
|
||||
'请结合实际情况,说明一下:',
|
||||
'如果方便的话,请详细解答:',
|
||||
'请用通俗易懂的方式说明:',
|
||||
'请从实际应用角度说明:',
|
||||
'请简洁、自然地说明:',
|
||||
]
|
||||
|
||||
export function createResults(
|
||||
items: PreviewItem[],
|
||||
options?: StructuredProcessOptions | UnstructuredProcessOptions,
|
||||
): ResultItem[] {
|
||||
const resultCount = options && 'qaPairsPerChunk' in options
|
||||
? Math.min(3, finiteInteger(options.qaPairsPerChunk, 1, 1))
|
||||
: Math.min(5, finiteInteger(options?.qaPairsPerRow, 1, 1))
|
||||
|
||||
const generatedResults = items.flatMap((item, index) => {
|
||||
if (options?.qualityFilterEnabled && options.filterLowQuality) {
|
||||
if (item.status === 'invalid' || !item.editedContent.trim()) return []
|
||||
}
|
||||
|
||||
const [firstLine = '', ...rest] = item.editedContent.split('\n')
|
||||
const output = rest.join('\n').trim() || item.editedContent.trim()
|
||||
const baseInstruction = firstLine.replace(/^问[::]\s*/, '').trim() || `数据条目 ${index + 1}`
|
||||
|
||||
return Array.from({ length: resultCount }, (_, variantIndex) => {
|
||||
const instruction = options?.semanticEnrichment
|
||||
? `${SEMANTIC_PREFIXES[variantIndex]}${baseInstruction}`
|
||||
: variantIndex === 0
|
||||
? baseInstruction
|
||||
: `${baseInstruction}(问法 ${variantIndex + 1})`
|
||||
|
||||
return {
|
||||
id: resultCount === 1 ? `result-${index + 1}` : `result-${index + 1}-${variantIndex + 1}`,
|
||||
instruction,
|
||||
input: '',
|
||||
output,
|
||||
originalInstruction: instruction,
|
||||
originalInput: '',
|
||||
originalOutput: output,
|
||||
status: 'valid' as const,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!options?.qualityFilterEnabled) return generatedResults
|
||||
|
||||
return generatedResults.filter((result) => {
|
||||
if (options.filterLowQuality && (!result.instruction.trim() || !result.output.trim())) return false
|
||||
if (options.filterShortContent && result.output.trim().length < options.minOutputLength) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
118
frontend/src/views/data-process/create/types.ts
Normal file
118
frontend/src/views/data-process/create/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export type ProcessType = 'structured' | 'unstructured' | 'external'
|
||||
|
||||
export type StepId = 'create' | 'model' | 'upload' | 'preview' | 'generate' | 'results'
|
||||
|
||||
export type PreprocessOption =
|
||||
| 'clean_invalid'
|
||||
| 'detect_structure'
|
||||
| 'deduplicate'
|
||||
| 'normalize_format'
|
||||
| 'filter_anomaly'
|
||||
| 'desensitize'
|
||||
|
||||
export interface DatasetSplitOptions {
|
||||
train: number
|
||||
validation: number
|
||||
test: number
|
||||
}
|
||||
|
||||
export interface GenerationControlOptions {
|
||||
generationModelId: string | number | ''
|
||||
generationPrompt: string
|
||||
temperature: number
|
||||
maxTokens: number
|
||||
jsonMode: boolean
|
||||
qualityFilterEnabled: boolean
|
||||
filterLowQuality: boolean
|
||||
filterShortContent: boolean
|
||||
minOutputLength: number
|
||||
}
|
||||
|
||||
export interface StructuredProcessOptions extends GenerationControlOptions {
|
||||
preprocessOptions: PreprocessOption[]
|
||||
semanticEnrichment: boolean
|
||||
qaPairsPerRow: number
|
||||
datasetSplit: DatasetSplitOptions
|
||||
}
|
||||
|
||||
export type UnstructuredPreprocessOption =
|
||||
| 'clean_invalid_content'
|
||||
| 'detect_document_structure'
|
||||
| 'merge_short_content'
|
||||
| 'filter_low_quality'
|
||||
| 'deduplicate_content'
|
||||
| 'desensitize'
|
||||
| 'preserve_context'
|
||||
|
||||
export type ChunkMethod = 'semantic' | 'heading' | 'fixed' | 'custom'
|
||||
|
||||
export interface UnstructuredProcessOptions extends GenerationControlOptions {
|
||||
preprocessOptions: UnstructuredPreprocessOption[]
|
||||
chunkMethod: ChunkMethod
|
||||
chunkSize: number
|
||||
chunkOverlap: number
|
||||
minChunkSize: number
|
||||
customDelimiter: string
|
||||
preserveTables: boolean
|
||||
preserveCodeBlocks: boolean
|
||||
preserveLists: boolean
|
||||
semanticEnrichment: boolean
|
||||
qaPairsPerChunk: number
|
||||
datasetSplit: DatasetSplitOptions
|
||||
}
|
||||
|
||||
export interface ExternalDataSource {
|
||||
type: string
|
||||
url: string
|
||||
authMode: string
|
||||
username?: string
|
||||
password?: string
|
||||
token?: string
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface UploadedDataFile {
|
||||
uid: string | number
|
||||
name: string
|
||||
size: number
|
||||
count: number
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SourceLine {
|
||||
number: number
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface PreviewItem {
|
||||
id: string
|
||||
sourceFileId: string
|
||||
originalContent: string
|
||||
editedContent: string
|
||||
sourceStart: number | null
|
||||
sourceEnd: number | null
|
||||
sourceStartLine: number | null
|
||||
sourceEndLine: number | null
|
||||
tokenCount: number
|
||||
status: 'original' | 'modified' | 'manual' | 'invalid'
|
||||
}
|
||||
|
||||
export interface GenerationState {
|
||||
status: 'idle' | 'running' | 'success' | 'failed'
|
||||
progress: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ResultItem {
|
||||
id: string
|
||||
instruction: string
|
||||
input: string
|
||||
output: string
|
||||
originalInstruction: string
|
||||
originalInput: string
|
||||
originalOutput: string
|
||||
status: 'valid' | 'modified' | 'invalid'
|
||||
error?: string
|
||||
}
|
||||
147
frontend/src/views/data-process/create/useDataProcessDraft.ts
Normal file
147
frontend/src/views/data-process/create/useDataProcessDraft.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { nextTick, type Reactive, type Ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type {
|
||||
ExternalDataSource,
|
||||
ProcessType,
|
||||
StepId,
|
||||
StructuredProcessOptions,
|
||||
UnstructuredProcessOptions,
|
||||
} from './types'
|
||||
|
||||
export const DATA_PROCESS_DRAFT_STORAGE_KEY = 'yg-data-process-create-draft'
|
||||
export const DATA_PROCESS_DRAFT_SCHEMA_VERSION = 6
|
||||
|
||||
interface DraftSnapshot {
|
||||
schemaVersion?: number
|
||||
currentStepId?: StepId
|
||||
task?: { name?: string; description?: string }
|
||||
processType?: ProcessType
|
||||
structuredOptions?: Partial<StructuredProcessOptions>
|
||||
unstructuredOptions?: Partial<UnstructuredProcessOptions>
|
||||
externalSource?: Partial<ExternalDataSource>
|
||||
}
|
||||
|
||||
interface DraftBindings {
|
||||
currentStepId: Readonly<Ref<StepId>>
|
||||
task: Reactive<{ name: string; description: string }>
|
||||
processType: Ref<ProcessType>
|
||||
structuredOptions: Ref<StructuredProcessOptions>
|
||||
unstructuredOptions: Ref<UnstructuredProcessOptions>
|
||||
externalSource: Reactive<ExternalDataSource>
|
||||
restoringDraft: Ref<boolean>
|
||||
dirty: Ref<boolean>
|
||||
goToStep: (stepId: StepId) => void
|
||||
}
|
||||
|
||||
function sanitizeExternalSource(source: Partial<ExternalDataSource>) {
|
||||
return {
|
||||
type: typeof source.type === 'string' ? source.type : 'mysql',
|
||||
url: typeof source.url === 'string' ? source.url : '',
|
||||
authMode: typeof source.authMode === 'string' ? source.authMode : 'none',
|
||||
username: typeof source.username === 'string' ? source.username : '',
|
||||
limit: Number.isFinite(source.limit) ? Number(source.limit) : 1000,
|
||||
}
|
||||
}
|
||||
|
||||
function isDraftSnapshot(value: unknown): value is DraftSnapshot {
|
||||
return Boolean(value && typeof value === 'object')
|
||||
}
|
||||
|
||||
/**
|
||||
* 只持久化可重建的配置。密码、令牌、文件正文、预览与结果都不进入 localStorage。
|
||||
*/
|
||||
export function useDataProcessDraft(bindings: DraftBindings) {
|
||||
function draftSnapshot(): DraftSnapshot {
|
||||
return {
|
||||
schemaVersion: DATA_PROCESS_DRAFT_SCHEMA_VERSION,
|
||||
currentStepId: bindings.currentStepId.value,
|
||||
task: { ...bindings.task },
|
||||
processType: bindings.processType.value,
|
||||
structuredOptions: {
|
||||
...bindings.structuredOptions.value,
|
||||
preprocessOptions: [...bindings.structuredOptions.value.preprocessOptions],
|
||||
datasetSplit: { ...bindings.structuredOptions.value.datasetSplit },
|
||||
},
|
||||
unstructuredOptions: {
|
||||
...bindings.unstructuredOptions.value,
|
||||
preprocessOptions: [...bindings.unstructuredOptions.value.preprocessOptions],
|
||||
datasetSplit: { ...bindings.unstructuredOptions.value.datasetSplit },
|
||||
},
|
||||
externalSource: sanitizeExternalSource(bindings.externalSource),
|
||||
}
|
||||
}
|
||||
|
||||
function writeDraft(showWarning: boolean) {
|
||||
try {
|
||||
localStorage.setItem(DATA_PROCESS_DRAFT_STORAGE_KEY, JSON.stringify(draftSnapshot()))
|
||||
} catch {
|
||||
if (showWarning) ElMessage.warning('草稿保存失败,请检查浏览器存储空间')
|
||||
}
|
||||
}
|
||||
|
||||
function persistDraft() {
|
||||
if (!bindings.restoringDraft.value) writeDraft(true)
|
||||
}
|
||||
|
||||
function restoreDraft() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DATA_PROCESS_DRAFT_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const snapshot: unknown = JSON.parse(raw)
|
||||
if (!isDraftSnapshot(snapshot)) return
|
||||
|
||||
bindings.restoringDraft.value = true
|
||||
bindings.goToStep('create')
|
||||
bindings.task.name = snapshot.task?.name || ''
|
||||
bindings.task.description = snapshot.task?.description || ''
|
||||
bindings.processType.value = snapshot.processType === 'unstructured' || snapshot.processType === 'external'
|
||||
? snapshot.processType
|
||||
: 'structured'
|
||||
|
||||
if (snapshot.structuredOptions) {
|
||||
bindings.structuredOptions.value = {
|
||||
...bindings.structuredOptions.value,
|
||||
...snapshot.structuredOptions,
|
||||
preprocessOptions: Array.isArray(snapshot.structuredOptions.preprocessOptions)
|
||||
? snapshot.structuredOptions.preprocessOptions
|
||||
: bindings.structuredOptions.value.preprocessOptions,
|
||||
datasetSplit: {
|
||||
...bindings.structuredOptions.value.datasetSplit,
|
||||
...snapshot.structuredOptions.datasetSplit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot.unstructuredOptions) {
|
||||
bindings.unstructuredOptions.value = {
|
||||
...bindings.unstructuredOptions.value,
|
||||
...snapshot.unstructuredOptions,
|
||||
preprocessOptions: Array.isArray(snapshot.unstructuredOptions.preprocessOptions)
|
||||
? snapshot.unstructuredOptions.preprocessOptions
|
||||
: bindings.unstructuredOptions.value.preprocessOptions,
|
||||
datasetSplit: {
|
||||
...bindings.unstructuredOptions.value.datasetSplit,
|
||||
...snapshot.unstructuredOptions.datasetSplit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(bindings.externalSource, sanitizeExternalSource(snapshot.externalSource || {}), {
|
||||
password: '',
|
||||
token: '',
|
||||
})
|
||||
bindings.dirty.value = false
|
||||
|
||||
nextTick(() => {
|
||||
bindings.restoringDraft.value = false
|
||||
// 立即覆盖 v5 及更早草稿,清除其中可能存在的敏感值和大段正文。
|
||||
writeDraft(false)
|
||||
})
|
||||
ElMessage.info('已恢复上次的任务配置,请重新上传或拉取源数据')
|
||||
} catch {
|
||||
localStorage.removeItem(DATA_PROCESS_DRAFT_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
return { draftSnapshot, persistDraft, restoreDraft }
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { reactive, ref, type Ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createResults } from './previewModel'
|
||||
import type {
|
||||
GenerationState,
|
||||
PreviewItem,
|
||||
ProcessType,
|
||||
ResultItem,
|
||||
StructuredProcessOptions,
|
||||
UnstructuredProcessOptions,
|
||||
} from './types'
|
||||
|
||||
interface GenerationBindings {
|
||||
previewItems: Ref<PreviewItem[]>
|
||||
processType: Ref<ProcessType>
|
||||
structuredOptions: Ref<StructuredProcessOptions>
|
||||
unstructuredOptions: Ref<UnstructuredProcessOptions>
|
||||
dirty: Ref<boolean>
|
||||
}
|
||||
|
||||
export function useDataProcessGeneration(bindings: GenerationBindings) {
|
||||
const results = ref<ResultItem[]>([])
|
||||
const selectedResultId = ref<string | null>(null)
|
||||
const generation = reactive<GenerationState>({
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
message: '确认摘要后即可开始生成,过程中可查看实时进度。',
|
||||
})
|
||||
let generationTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stopGenerationTimer() {
|
||||
if (generationTimer) clearInterval(generationTimer)
|
||||
generationTimer = null
|
||||
}
|
||||
|
||||
function resetDownstream() {
|
||||
stopGenerationTimer()
|
||||
generation.status = 'idle'
|
||||
generation.progress = 0
|
||||
generation.message = '确认摘要后即可开始生成,过程中可查看实时进度。'
|
||||
results.value = []
|
||||
selectedResultId.value = null
|
||||
}
|
||||
|
||||
function startGeneration() {
|
||||
stopGenerationTimer()
|
||||
generation.status = 'running'
|
||||
generation.progress = 0
|
||||
generation.message = '正在应用预览修改并生成标准化结果,请稍候。'
|
||||
|
||||
generationTimer = setInterval(() => {
|
||||
generation.progress = Math.min(100, generation.progress + 8)
|
||||
if (generation.progress < 100) return
|
||||
|
||||
stopGenerationTimer()
|
||||
generation.status = 'success'
|
||||
results.value = createResults(
|
||||
bindings.previewItems.value,
|
||||
bindings.processType.value === 'structured'
|
||||
? bindings.structuredOptions.value
|
||||
: bindings.processType.value === 'unstructured'
|
||||
? bindings.unstructuredOptions.value
|
||||
: undefined,
|
||||
)
|
||||
generation.message = `已完成 ${results.value.length.toLocaleString()} 条数据处理,可进入结果页检查。`
|
||||
selectedResultId.value = results.value[0]?.id ?? null
|
||||
bindings.dirty.value = true
|
||||
ElMessage.success('数据处理完成')
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
stopGenerationTimer()
|
||||
generation.status = 'failed'
|
||||
generation.message = '任务已停止,预览修改仍然保留,可以重新生成。'
|
||||
}
|
||||
|
||||
function updateResultField(id: string, field: 'instruction' | 'input' | 'output', value: string) {
|
||||
const item = results.value.find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
item[field] = value
|
||||
const valid = item.instruction.trim() && item.output.trim()
|
||||
item.error = valid ? undefined : 'Instruction 和 Output 不能为空'
|
||||
const changed = item.instruction !== item.originalInstruction
|
||||
|| item.input !== item.originalInput
|
||||
|| item.output !== item.originalOutput
|
||||
item.status = item.error ? 'invalid' : changed ? 'modified' : 'valid'
|
||||
bindings.dirty.value = true
|
||||
}
|
||||
|
||||
function restoreResult(id: string) {
|
||||
const item = results.value.find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
item.instruction = item.originalInstruction
|
||||
item.input = item.originalInput
|
||||
item.output = item.originalOutput
|
||||
item.error = undefined
|
||||
item.status = 'valid'
|
||||
bindings.dirty.value = true
|
||||
}
|
||||
|
||||
function validateResults() {
|
||||
let firstInvalidId: string | null = null
|
||||
for (const item of results.value) {
|
||||
if (!item.instruction.trim() || !item.output.trim()) {
|
||||
item.error = 'Instruction 和 Output 不能为空'
|
||||
item.status = 'invalid'
|
||||
firstInvalidId ??= item.id
|
||||
}
|
||||
}
|
||||
if (firstInvalidId) selectedResultId.value = firstInvalidId
|
||||
return firstInvalidId == null
|
||||
}
|
||||
|
||||
return {
|
||||
generation,
|
||||
results,
|
||||
selectedResultId,
|
||||
resetDownstream,
|
||||
restoreResult,
|
||||
startGeneration,
|
||||
stopGeneration,
|
||||
stopGenerationTimer,
|
||||
updateResultField,
|
||||
validateResults,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user