Files
PlantAI-rag-new/ui/src/components/chat/FlowFormCard.vue
T
admin 963a87855c feat(bioflow): 生信模块对话式改造 + MCP 外挂化 (S0-S6)
S0: bioflow_service 三 MCP 端点(gene-family/rnaseq/extension) + 源文件获取/预处理
S1: QA bioflow_agent 前门 router + capabilities.yaml 三条目 + 熔断
S2: card_result 回填 + 多轮记忆 + session↔job 绑定
S3: 前端 TAB 双信号 + 卡片辅助化 + 进度块只读 + 删除直连
S4: 扩展子系统对话化(第三能力单元)
S5: toggle_bioflow.sh 装卸 + SettingsPanel 三开关
S6: 多配置回归 + 对话剧本测试手册
2026-10-11 10:45:25 +08:00

415 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
// chat/FlowFormCard.vue —— 生信参数表单卡(FR-BIO-008/009/010,UI_SPEC §8.2 逐字复刻)
// 标题「⚙ XXXX 步骤 · 可调整参数」12.5px/600/ls .3px;前置文案(FR-BIO-008 对话告知)
// 字段行:label 12px ink-muted 宽 96px 固定;input surface-1 + hairline,r-sm,
// padding 6px 10px,focus border --pa-g-from;来源标注每字段下方 11px ink-subtle
// (契约文档/参数预填/存档名三来源)
// 继续按钮「继续」--pa-gradient 白字 12.5px/600 r-md padding 7px 16px
// 自动连续开关「自动连续执行」12px ink-muted + mode-pill 同款开关(FR-BIO-010)
// 校验:数字类型/choices/min-max;Esc 恢复默认值;Enter 提交;提交中禁用+防重复。
import { computed, reactive, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useBioflowStore } from '../../stores/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({
block: { type: Object, required: true },
});
const emit = defineEmits(['submitted', 'error']);
const bioflow = useBioflowStore();
const chat = useChatStore();
const fields = computed(() => props.block.fields || []);
const stage = computed(() => props.block.stage || '');
const rerunMode = computed(() => !!props.block.rerun);
const reuseMode = computed(() => props.block.mode === 'reuse'); // FR-BIO-023 复用预填表单
// 编辑值副本(Esc 恢复默认值用;提交时按 name 收集)
const values = reactive({});
const errors = reactive({});
const submitting = ref(false);
const submitError = ref('');
function syncDefaults() {
for (const f of fields.value) {
if (!(f.name in values)) values[f.name] = f.default ?? '';
}
}
syncDefaults();
/** 自动连续开关(FR-BIO-010):任务级标志存 bioflow store;
* 开启后事件流在后续 stage_await_confirm 自动按默认值 confirm(见 chat.sendBioflow) */
const autoContinue = computed({
get: () => bioflow.currentJob.autoContinue,
set: (v) => { bioflow.currentJob.autoContinue = !!v; },
});
/** 来源标注(契约文档/参数预填/存档名;source=archive 时显示存档名) */
function sourceLabel(f) {
const src = f.source || 'contract';
if (src === 'archive') return BF.formSourceArchive.replace('{name}', f.archiveName || '');
if (src === 'prefill' || src === 'runtime') return BF.formSourcePrefill;
if (src === 'user') return BF.formSourceUser;
if (src === 'contract') return BF.formSourceContract;
return BF.formSourceUnknown;
}
/** 复用预填模式:值从块 prefill 读取({value, source, archiveName}),直接可编辑提交 */
function displayValue(f) {
if (!reuseMode.value) return values[f.name];
const pre = (props.block.prefill && props.block.prefill[f.name]) || null;
return pre && typeof pre === 'object' ? pre.value : (pre ?? f.default ?? '');
}
function setDisplayValue(f, v) {
if (!reuseMode.value) {
values[f.name] = v;
return;
}
const pre = (props.block.prefill && props.block.prefill[f.name]) || null;
const meta = pre && typeof pre === 'object' ? pre : { value: pre, source: 'archive' };
props.block.prefill = { ...(props.block.prefill || {}), [f.name]: { ...meta, value: v } };
}
function displaySource(f) {
if (!reuseMode.value) return sourceLabel(f);
const pre = (props.block.prefill && props.block.prefill[f.name]) || null;
const meta = pre && typeof pre === 'object' ? pre : null;
if (meta && meta.source === 'archive' && meta.archiveName) {
return BF.formSourceArchive.replace('{name}', meta.archiveName);
}
if (meta && meta.source === 'archive') return BF.formSourceArchive.replace('{name}', '');
return sourceLabel({ ...f, source: (meta && meta.source) || f.source || 'contract' });
}
function fieldValue(f) {
return values[f.name];
}
/** 校验:数字类型 + min/max + choices(TC-BIO-FORM-014/016;不合法不发请求) */
function validateField(f) {
const v = reuseMode.value ? displayValue(f) : values[f.name];
if (v === '' || v === null || v === undefined) return ''; // 空值交给后端按默认处理
const type = f.type || 'string';
if (type === 'int' || type === 'float' || type === 'number') {
const n = Number(v);
if (!Number.isFinite(n)) return BF.formTypeError;
if (f.min != null && n < f.min) return BF.formRangeError.replace('{min}', f.min).replace('{max}', f.max ?? '');
if (f.max != null && n > f.max) return BF.formRangeError.replace('{min}', f.min ?? '').replace('{max}', f.max);
}
if (Array.isArray(f.choices) && f.choices.length && !f.choices.some((c) => String(c) === String(v))) {
return BF.formTypeError;
}
return '';
}
function validateAll() {
let ok = true;
for (const f of fields.value) {
const err = validateField(f);
errors[f.name] = err;
if (err) ok = false;
}
return ok;
}
/** 收集提交参数:类型归一(数字→Number,与后端参数校验一致) */
function collectParams() {
const params = {};
for (const f of fields.value) {
const v = reuseMode.value ? displayValue(f) : values[f.name];
if (v === '' || v === null || v === undefined) continue; // 可选留空不发送
if (f.type === 'int') params[f.name] = parseInt(v, 10);
else if (f.type === 'float' || f.type === 'number') params[f.name] = parseFloat(v);
else if (f.type === 'bool' || f.type === 'boolean') params[f.name] = v === true || v === 'true';
else params[f.name] = String(v);
}
return params;
}
/** 继续:确认阶段(FR-BIO-009);改参数重跑场景走 rerun 带参数(FR-BIO-012);
* 复用预填模式(FR-BIO-023)创建按存档参数发起的任务,不确认阶段 */
async function submit() {
if (submitting.value) return;
if (!validateAll()) return;
submitting.value = true;
submitError.value = '';
const params = collectParams();
try {
if (reuseMode.value) {
// 复用发起:POST archives/{id}/run(后端已合并存档参数)
const archiveId = props.block.archiveId;
if (!archiveId) throw new Error('no archive');
const detail = await bioflow.runArchive(archiveId, {
sessionId: props.block.sessionId || '',
target: props.block.target || '',
params,
});
props.block.jobId = detail.id || null;
props.block.submitted = true;
emit('submitted', { stage: stage.value, params, reuse: true, jobId: detail.id });
} else if (rerunMode.value) {
await bioflow.rerunStage(stage.value, params);
props.block.submitted = true;
emit('submitted', { stage: stage.value, params });
} else {
const jobId = bioflow.currentJob.jobId || props.block.jobId;
if (!jobId) throw new Error('no job');
// S3:卡片不直连控制面——参数确认改为 card_result 消息进 QA 流(BioflowAgent 经 confirm_stage 执行)
await chat.sendCardResult('form_confirm', { job_id: jobId, stage: stage.value, params });
props.block.submitted = true;
emit('submitted', { stage: stage.value, params });
}
} catch (err) {
submitError.value = err?.message || BF.formSubmitError;
emit('error', err);
} finally {
submitting.value = false;
}
}
/** Esc 恢复默认值(TC-BIO-FORM-024:放弃编辑两态断言之一) */
function onEsc() {
if (reuseMode.value) return; // 复用模式预填值不改回(档存值即默认)
for (const f of fields.value) values[f.name] = f.default ?? '';
}
/** Enter 触发继续(TC-BIO-FORM-023;输入法组合键不触发) */
function onKeydown(e, f) {
if (e.isComposing) return;
if (e.key === 'Enter') {
e.preventDefault();
submit();
} else if (e.key === 'Escape') {
onEsc();
}
}
// 复用模式标题与按钮文案(FR-BIO-023)
const titleText = computed(() => {
if (reuseMode.value) {
const name = (props.block.archiveName || '').replace(/\{name\}/g, '');
return BF.reuseFormTitle.replace('{name}', name || props.block.archiveName || '');
}
return BF.formTitle.replace('{stage}', stage.value);
});
const continueText = computed(() => {
if (submitting.value) return BF.formSubmitting;
if (reuseMode.value) return props.block.confirming ? BF.reuseFormCreating : BF.reuseFormCreate;
return BF.formContinue;
});
</script>
<template>
<div class="flow-form-card">
<div class="ffc-title">{{ titleText }}</div>
<div v-if="reuseMode" class="ffc-prefix">{{ BF.reuseFormHint }}</div>
<div v-else class="ffc-prefix">{{ BF.formPrefix.replace('{stage}', stage) }}</div>
<div v-for="f in fields" :key="f.name" class="ffc-field">
<label class="ffc-label" :for="'bioflow-' + stage + '-' + f.name">{{ f.name }}</label>
<div class="ffc-input-wrap">
<select
v-if="Array.isArray(f.choices) && f.choices.length"
:id="'bioflow-' + stage + '-' + f.name"
:value="displayValue(f)"
class="ffc-input"
:disabled="submitting"
@change="(e) => setDisplayValue(f, e.target.value)"
>
<option v-for="c in f.choices" :key="c" :value="c">{{ c }}</option>
</select>
<input
v-else
:id="'bioflow-' + stage + '-' + f.name"
:value="displayValue(f)"
class="ffc-input"
:class="{ invalid: errors[f.name] }"
:type="f.type === 'int' || f.type === 'float' || f.type === 'number' ? 'text' : 'text'"
:disabled="submitting"
@input="(e) => setDisplayValue(f, e.target.value)"
@keydown="(e) => onKeydown(e, f)"
/>
</div>
<div class="ffc-meta">
<span class="ffc-source">{{ displaySource(f) }}</span>
<span v-if="errors[f.name]" class="ffc-err">{{ errors[f.name] }}</span>
</div>
</div>
<div class="ffc-footer">
<label class="ffc-auto">
<span class="ffc-auto-label">{{ BF.formAutoLabel }}</span>
<button
type="button"
class="mode-pill"
:class="{ on: autoContinue }"
:aria-pressed="autoContinue"
@click="autoContinue = !autoContinue"
@keydown.enter.prevent="autoContinue = !autoContinue"
>{{ autoContinue ? 'ON' : 'OFF' }}</button>
</label>
<button
v-if="!props.block.submitted"
class="ffc-continue"
type="button"
:disabled="submitting"
@click="submit"
>{{ continueText }}</button>
<span v-else class="ffc-confirmed">{{ BF.formConfirmed }}</span>
</div>
<div v-if="submitError" class="ffc-submit-err">{{ submitError }}</div>
</div>
</template>
<style scoped>
/* UI_SPEC §8.2 逐字复刻 */
.flow-form-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ffc-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 4px;
}
.ffc-prefix {
font-size: 12px;
color: var(--pa-ink-muted);
margin-bottom: 10px;
}
.ffc-field {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
}
/* label 12px ink-muted 宽 96px 固定 */
.ffc-label {
flex: none;
width: 96px;
padding-top: 7px;
font-size: 12px;
color: var(--pa-ink-muted);
font-family: var(--pa-font-mono);
word-break: break-all;
}
.ffc-input-wrap {
flex: 1;
min-width: 0;
}
/* input surface-1 + hairline,r-sm,padding 6px 10px,focus border --pa-g-from */
.ffc-input {
width: 100%;
padding: 6px 10px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
color: var(--pa-ink);
font-size: 12.5px;
font-family: var(--pa-font-mono);
outline: none;
transition: border-color 0.18s var(--pa-ease);
}
.ffc-input:focus {
border-color: var(--pa-g-from);
}
.ffc-input.invalid {
border-color: var(--pa-error);
}
.ffc-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 来源标注:每字段下方 11px ink-subtle */
.ffc-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 3px;
}
.ffc-source {
font-size: 11px;
color: var(--pa-ink-subtle);
}
.ffc-err {
font-size: 11px;
color: var(--pa-error);
}
.ffc-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 4px;
}
/* 自动连续开关(FR-BIO-010):12px ink-muted + mode-pill 同款开关 */
.ffc-auto {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.ffc-auto-label {
font-size: 12px;
color: var(--pa-ink-muted);
}
.mode-pill {
padding: 4px 12px;
border-radius: var(--pa-r-pill);
font-size: 12px;
font-weight: 500;
color: var(--pa-ink-muted);
border: 1px solid var(--pa-hairline);
background: none;
transition: all 0.2s var(--pa-ease);
cursor: pointer;
}
.mode-pill:hover {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
.mode-pill.on {
color: #fff;
background: var(--pa-gradient);
border-color: transparent;
box-shadow: 0 0 12px rgba(0, 212, 255, 0.3);
}
/* 继续按钮:「继续」--pa-gradient 白字 12.5px/600,r-md padding 7px 16px */
.ffc-continue {
padding: 7px 16px;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.3s var(--pa-ease), transform 0.15s var(--pa-ease), opacity 0.2s var(--pa-ease);
}
.ffc-continue:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
.ffc-continue:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.ffc-confirmed {
font-size: 12.5px;
font-weight: 600;
color: var(--pa-ink-subtle);
}
.ffc-submit-err {
margin-top: 8px;
font-size: 12px;
color: var(--pa-error);
}
</style>