Merge branch 'dev/extension-frontend' into feature/bioflow-conversation-orchestration

This commit is contained in:
2026-09-14 13:49:18 +08:00
19 changed files with 4034 additions and 6 deletions
+72
View File
@@ -127,3 +127,75 @@ export function runBioflowArchiveByName(body, opts = {}) {
export function registerBioflowReport(body, opts = {}) {
return post('/api/bioflow/reports/register', body, opts);
}
// ---------------------------------------------------------------
// 用户自定义扩展分析(EXTENSION_CONTRACT.md §6 端点清单)
// 生命周期:draft → build → trial_run → validate/confirm → repair → publish → run。
// 与任务端点同风格:全部经 BFF 反代(浏览器同源 /api/bioflow/extensions/*),
// 底层走 http.js 统一错误信封;SSE 流 raw 透传由调用方消费。
// ---------------------------------------------------------------
/** POST /api/bioflow/extensions —— 上传 zip 创建扩展。
* body 为 FormData(multipart):zip 文件 + display_name + command +
* runtime_image?(8 base 镜像) + golden?(可选金标输出文件)。 */
export function createExtension(body, opts = {}) {
return post('/api/bioflow/extensions', body, {
timeout: 0, // 上传不设前端超时(与 uploadBioflowFile 同约定)
...opts,
});
}
/** GET /api/bioflow/extensions —— 列表(published 优先) */
export function listExtensions(params = {}, opts = {}) {
return get('/api/bioflow/extensions', params, opts);
}
/** GET /api/bioflow/extensions/{id} —— 详情(extension.json) */
export function getExtension(extId, opts = {}) {
return get(`/api/bioflow/extensions/${extId}`, {}, opts);
}
/** DELETE /api/bioflow/extensions/{id} */
export function deleteExtension(extId, opts = {}) {
return del(`/api/bioflow/extensions/${extId}`, opts);
}
/** POST /api/bioflow/extensions/{id}/build —— AI 转换(命令 → command_template) */
export function buildExtension(extId, body = {}, opts = {}) {
return post(`/api/bioflow/extensions/${extId}/build`, body, opts);
}
/** POST /api/bioflow/extensions/{id}/trial-run —— 容器试运行(test_inputs 文件) */
export function trialRunExtension(extId, body = {}, opts = {}) {
return post(`/api/bioflow/extensions/${extId}/trial-run`, body, opts);
}
/** POST /api/bioflow/extensions/{id}/repair —— LLM 修复循环(上限 20 轮可配) */
export function repairExtension(extId, body = {}, opts = {}) {
return post(`/api/bioflow/extensions/${extId}/repair`, body, opts);
}
/** POST /api/bioflow/extensions/{id}/publish —— 发布(nl_triggers 可编辑) */
export function publishExtension(extId, body = {}, opts = {}) {
return post(`/api/bioflow/extensions/${extId}/publish`, body, opts);
}
/** POST /api/bioflow/extensions/{id}/run —— 正式运行(params + files → run_id) */
export function runExtension(extId, body = {}, opts = {}) {
return post(`/api/bioflow/extensions/${extId}/run`, body, opts);
}
/** GET /api/bioflow/extensions/{id}/runs —— 运行历史 */
export function listExtensionRuns(extId, params = {}, opts = {}) {
return get(`/api/bioflow/extensions/${extId}/runs`, params, opts);
}
/** GET /api/bioflow/extensions/{id}/runs/{run_id}/events —— SSE(首帧快照)。
* 返回原始 fetch Response(SSE 由调用方消费)。 */
export function extensionRunEvents(extId, runId, params = {}, opts = {}) {
return get(`/api/bioflow/extensions/${extId}/runs/${runId}/events`, params, {
raw: true,
timeout: 0,
...opts,
});
}
@@ -17,6 +17,15 @@ import FlowFormCard from './FlowFormCard.vue';
import FlowFileCard from './FlowFileCard.vue';
import FlowProgressCard from './FlowProgressCard.vue';
import FlowArchiveCard from './FlowArchiveCard.vue';
// 用户自定义扩展分析:生命周期引导卡 + 运行卡(EXTENSION_CONTRACT.md §4 块)
import ExtensionUploadCard from './ExtensionUploadCard.vue';
import ExtensionBuildCard from './ExtensionBuildCard.vue';
import ExtensionTrialCard from './ExtensionTrialCard.vue';
import ExtensionValidateCard from './ExtensionValidateCard.vue';
import ExtensionPublishCard from './ExtensionPublishCard.vue';
import ExtensionCandidatesCard from './ExtensionCandidatesCard.vue';
import ExtensionConfirmCard from './ExtensionConfirmCard.vue';
import ExtensionRunCard from './ExtensionRunCard.vue';
// FR-PRIV-005:私有资料参考块(独立 i18n 文件,不碰 labels.js 红线)
import { PRIV } from '../../i18n/private.js';
@@ -224,6 +233,59 @@ const degradedNote = computed(() => props.message.degradedNote || null);
<div v-if="!b.citations || !b.citations.length" class="bf-qa-empty">{{ BF.flowQaEmpty }}</div>
</div>
<!-- 用户自定义扩展分析:上传引导卡(extension_upload) -->
<ExtensionUploadCard
v-if="b.type === 'extension_upload'"
:block="b"
/>
<!-- 用户自定义扩展分析:AI 转换卡(extension_build) -->
<ExtensionBuildCard
v-if="b.type === 'extension_build'"
:block="b"
/>
<!-- 用户自定义扩展分析:试运行卡(extension_trial,repair 同卡刷新) -->
<ExtensionTrialCard
v-if="b.type === 'extension_trial'"
:block="b"
/>
<!-- 用户自定义扩展分析:金标/确认卡(extension_validate) -->
<ExtensionValidateCard
v-if="b.type === 'extension_validate'"
:block="b"
/>
<!-- 用户自定义扩展分析:发布卡(extension_publish) -->
<ExtensionPublishCard
v-if="b.type === 'extension_publish'"
:block="b"
/>
<!-- 用户自定义扩展分析:多命中候选卡(extension_candidates) -->
<ExtensionCandidatesCard
v-if="b.type === 'extension_candidates'"
:block="b"
/>
<!-- 用户自定义扩展分析:运行前参数确认卡(extension_confirm) -->
<ExtensionConfirmCard
v-if="b.type === 'extension_confirm'"
:block="b"
:message="message"
/>
<!-- 用户自定义扩展分析:运行进度卡(extension_run) -->
<ExtensionRunCard
v-if="b.type === 'extension_run'"
:block="b"
/>
<!-- 用户自定义扩展分析:发布确认块(extension_published) -->
<div v-if="b.type === 'extension_published'" class="ext-published-block">
<div class="ext-published-title">{{ BF.extPublishedTitle }}</div>
<div class="ext-published-hint">
{{ BF.extPublishedHint.replace('{name}', b.displayName || '') }}
<span v-if="b.nlTriggers && b.nlTriggers.length" class="ext-published-triggers">
{{ BF.extPublishTriggersLabel }}:{{ b.nlTriggers.join('、') }}
</span>
</div>
</div>
<!-- tool:阶段卡 -->
<ToolCard
v-if="b.type === 'tool'"
@@ -574,6 +636,34 @@ const degradedNote = computed(() => props.message.degradedNote || null);
flex-direction: column;
gap: 8px;
}
/* ---------- 用户自定义扩展分析:发布确认块(extension_published) ---------- */
.ext-published-block {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ext-published-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
color: var(--pa-success);
margin-bottom: 4px;
}
.ext-published-hint {
font-size: 12px;
color: var(--pa-ink-muted);
line-height: 1.6;
}
.ext-published-triggers {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--pa-ink-subtle);
word-break: break-all;
}
.bf-qa-cite {
display: flex;
align-items: flex-start;
+20 -2
View File
@@ -17,7 +17,7 @@ import { MODES as MODE_LABELS, T } from '../../i18n/labels.js';
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
import { BF } from '../../i18n/bioflow.js';
import { route } from '../../intent.js';
import { route as routeBioflow } from '../../utils/intent.js';
import { route as routeBioflow, isExtensionCreateIntent } from '../../utils/intent.js';
import { uploadLibrary, listLibrary } from '../../api/docgen.js';
import { uploadBioflowFile } from '../../api/bioflow.js';
import { useDocgenStore } from '../../stores/docgen.js';
@@ -244,7 +244,25 @@ const submit = () => {
bioflowRoute = { intent: 'qa', reuseName: null };
}
}
if (bioflowRoute.intent === 'bioflow_qa') {
// 用户自定义扩展分析:A 层创建意图(EXTENSION_CONTRACT.md §5 词典同表双跑;
// 「创建/上传 一个扩展」→ sendExtension 上传引导卡。歧义消解与运行触发由
// 服务端 intent.py 在 chat/stream 内判定——前端只做确定性创建识别,漏判安全。)
let extIntent = false;
if (bioflowRoute.intent !== 'bioflow_qa' && bioflowRoute.intent !== 'bioflow' && bioflowEnabled.value) {
try {
extIntent = isExtensionCreateIntent(text);
} catch {
extIntent = false; // 路由异常按漏判安全处理
}
}
if (extIntent) {
// 扩展创建引导流:chat.sendExtension(upload 引导卡 → extension_* 事件帧)
try {
useChatStore().sendExtension(text, { mode: 'ask' }).catch(() => { /* 内部已标注错误 */ });
} catch {
emit('send', text); // chat store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else if (bioflowRoute.intent === 'bioflow_qa') {
// 生信流内流程咨询(FR-BIO-026):也走 sendBioflow(后端流内返引用块,不新建任务)
try {
useChatStore().sendBioflow(text, { mode: 'ask' }).catch(() => { /* 内部已标注错误 */ });
@@ -0,0 +1,340 @@
<script setup>
// chat/ExtensionBuildCard.vue —— AI 转换卡(EXTENSION_CONTRACT.md §4 extension_build 块)
// 展示 command_template / 依赖(pip+apt) / 推荐镜像 / nl_triggers / analysis_type / parameters,
// 可编辑修正后提交(POST /extensions/{id}/build 重入);「跳过」直接采用 AI 结果。
// 复用 FlowFormCard 视觉令牌(字段行 label 96px / input 同款 / 主按钮 gradient)。
import { computed, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { buildExtension } from '../../api/bioflow.js';
const props = defineProps({
block: { type: Object, required: true },
});
const emit = defineEmits(['submitted', 'error']);
const submitting = ref(false);
const submitError = ref('');
// 编辑副本(提交时以副本覆盖块字段)
const templateDraft = ref('');
const pipDraft = ref('');
const aptDraft = ref('');
const imageDraft = ref('');
const triggersDraft = ref('');
const typeDraft = ref('');
const failed = computed(() => props.block.status === 'build_failed');
const complete = computed(() => !!props.block.commandTemplate);
function syncDrafts() {
templateDraft.value = props.block.commandTemplate || '';
pipDraft.value = (props.block.requirements?.pip || []).join(', ');
aptDraft.value = (props.block.requirements?.apt || []).join(', ');
imageDraft.value = props.block.runtimeImage || 'bioflow-gf';
triggersDraft.value = (props.block.nlTriggers || []).join(', ');
typeDraft.value = props.block.analysisType || '';
}
syncDrafts();
const params = computed(() => props.block.parameters || []);
const reqs = computed(() => props.block.requirements || { pip: [], apt: [] });
const hasDeps = computed(() =>
(Array.isArray(reqs.value.pip) && reqs.value.pip.length > 0)
|| (Array.isArray(reqs.value.apt) && reqs.value.apt.length > 0));
/** 提交修正:POST build 带修正值(后端按 ext_id 重入转换) */
async function submit({ skip = false } = {}) {
if (submitting.value) return;
submitting.value = true;
submitError.value = '';
try {
const body = {};
if (!skip) {
if (!templateDraft.value.trim()) { submitError.value = BF.extBuildFieldRequired; submitting.value = false; return; }
body.command_template = templateDraft.value.trim();
body.runtime_image = imageDraft.value.trim() || undefined;
body.nl_triggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
body.analysis_type = typeDraft.value.trim() || undefined;
}
await buildExtension(props.block.extId, body);
// 提交成功以本地修正为准(后端重入结果由 extension_build 帧再次刷新)
if (!skip) {
props.block.commandTemplate = templateDraft.value.trim();
props.block.runtimeImage = imageDraft.value.trim() || props.block.runtimeImage;
props.block.nlTriggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
props.block.analysisType = typeDraft.value.trim() || props.block.analysisType;
}
props.block.submitted = true;
emit('submitted', { extId: props.block.extId, skip });
} catch (err) {
submitError.value = err?.message || BF.extBuildError;
emit('error', err);
} finally {
submitting.value = false;
}
}
</script>
<template>
<div class="ext-card">
<div class="ext-card-title">
{{ BF.extBuildTitle }}
<span v-if="!complete" class="ext-badge" :class="failed ? 'error' : 'running'">
{{ failed ? BF.extBuildStatusFailed : BF.extBuildStatus }}
</span>
</div>
<div v-if="failed && block.error" class="ext-error">{{ block.error }}</div>
<template v-if="complete || failed">
<!-- 命令模板(可编辑修正) -->
<div class="ext-field">
<label class="ext-label">{{ BF.extBuildTemplateLabel }}</label>
<div class="ext-field-body">
<input v-model="templateDraft" class="ext-input" type="text" :disabled="submitting" />
<div class="ext-hint">{{ BF.extBuildTemplateHint }}</div>
</div>
</div>
<!-- 依赖探测结果(展示;pip/apt 由 AI 探测,不开放编辑以免破坏镜像构建) -->
<div class="ext-field">
<label class="ext-label">{{ BF.extBuildDepsLabel }}</label>
<div class="ext-field-body">
<div v-if="hasDeps" class="ext-deps">
<div v-if="reqs.pip && reqs.pip.length" class="ext-deps-row">
<span class="ext-deps-tag">{{ BF.extBuildPipLabel }}</span>
<span class="ext-deps-val">{{ reqs.pip.join(', ') }}</span>
</div>
<div v-if="reqs.apt && reqs.apt.length" class="ext-deps-row">
<span class="ext-deps-tag">{{ BF.extBuildAptLabel }}</span>
<span class="ext-deps-val">{{ reqs.apt.join(', ') }}</span>
</div>
</div>
<div v-else class="ext-hint">{{ BF.extBuildDepsEmpty }}</div>
</div>
</div>
<!-- 推荐镜像(可修正) -->
<div class="ext-field">
<label class="ext-label">{{ BF.extBuildImageLabel }}</label>
<div class="ext-field-body">
<input v-model="imageDraft" class="ext-input" type="text" :disabled="submitting" />
</div>
</div>
<!-- nl_triggers(可编辑,逗号分隔) -->
<div class="ext-field">
<label class="ext-label">{{ BF.extBuildTriggersLabel }}</label>
<div class="ext-field-body">
<input v-model="triggersDraft" class="ext-input" type="text" :disabled="submitting" />
<div class="ext-hint">{{ BF.extBuildTriggersHint }}</div>
</div>
</div>
<!-- analysis_type -->
<div class="ext-field">
<label class="ext-label">{{ BF.extBuildTypeLabel }}</label>
<div class="ext-field-body">
<input v-model="typeDraft" class="ext-input" type="text" :disabled="submitting" />
</div>
</div>
<!-- parameters(只读展示) -->
<div class="ext-field">
<label class="ext-label">{{ BF.extBuildParamsLabel }}</label>
<div class="ext-field-body">
<div v-if="params.length" class="ext-params">
<span v-for="p in params" :key="p.name" class="ext-param">
{{ p.name }}<span class="ext-param-type">{{ p.type || 'string' }}</span>
</span>
</div>
<div v-else class="ext-hint">{{ BF.extBuildParamsEmpty }}</div>
</div>
</div>
<div class="ext-actions">
<button class="pane-btn" type="button" :disabled="submitting" @click="submit({ skip: true })">
{{ BF.extBuildSkip }}
</button>
<button class="ext-primary" type="button" :disabled="submitting" @click="submit({ skip: false })">
{{ submitting ? BF.extBuildSubmitting : BF.extBuildSubmit }}
</button>
</div>
<div v-if="submitError" class="ext-error">{{ submitError }}</div>
</template>
</div>
</template>
<style scoped>
/* FlowFormCard §8.2 令牌同款 */
.ext-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ext-card-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.ext-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--pa-r-pill);
}
.ext-badge.running {
color: var(--pa-warning);
background: rgba(245, 184, 92, 0.12);
}
.ext-badge.error {
color: var(--pa-error);
background: rgba(255, 92, 122, 0.12);
}
.ext-field {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
}
.ext-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;
}
.ext-field-body {
flex: 1;
min-width: 0;
}
.ext-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);
}
.ext-input:focus {
border-color: var(--pa-g-from);
}
.ext-input:disabled {
opacity: 0.5;
}
.ext-hint {
display: block;
margin-top: 3px;
font-size: 11px;
color: var(--pa-ink-subtle);
line-height: 1.6;
}
.ext-deps {
display: flex;
flex-direction: column;
gap: 4px;
}
.ext-deps-row {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
}
.ext-deps-tag {
flex: none;
font-family: var(--pa-font-mono);
font-size: 11px;
color: var(--pa-g-to);
background: color-mix(in srgb, var(--pa-g-from) 14%, transparent);
border-radius: var(--pa-r-xs);
padding: 1px 6px;
}
.ext-deps-val {
color: var(--pa-ink-muted);
font-family: var(--pa-font-mono);
word-break: break-all;
}
.ext-params {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ext-param {
font-family: var(--pa-font-mono);
font-size: 12px;
color: var(--pa-ink);
background: var(--pa-surface-2);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-xs);
padding: 2px 8px;
}
.ext-param-type {
margin-left: 6px;
font-size: 11px;
color: var(--pa-ink-subtle);
}
.ext-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.pane-btn {
padding: 7px 13px;
border-radius: var(--pa-r-md);
border: 1px solid var(--pa-hairline);
background: var(--pa-surface-2);
color: var(--pa-ink-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.18s var(--pa-ease);
}
.pane-btn:hover:not(:disabled) {
color: var(--pa-ink);
background: var(--pa-surface-3);
}
.pane-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ext-primary {
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);
}
.ext-primary:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
.ext-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.ext-error {
margin-bottom: 8px;
font-size: 12px;
color: var(--pa-error);
}
</style>
@@ -0,0 +1,96 @@
<script setup>
// chat/ExtensionCandidatesCard.vue —— 多命中候选卡(EXTENSION_CONTRACT.md §4 extension_candidates 块)
// 多个已发布扩展命中同一请求:列出 display_name + purpose(用途),点击选择。
// 选中后 emit('pick')(后续确认链由后端 intent 分支驱动 extension_confirm 帧)。
// 复用 FlowPathCard 候选行视觉令牌(hover surface-3 / 选中 gradient 1.5px 描边 + 8% 混色底)。
import { computed } from 'vue';
import { BF } from '../../i18n/bioflow.js';
const props = defineProps({
block: { type: Object, required: true },
});
const emit = defineEmits(['pick']);
const selected = computed({
get: () => (typeof props.block.selected === 'number' ? props.block.selected : null),
set: (v) => { props.block.selected = v; },
});
const candidates = computed(() => props.block.candidates || []);
function pick(i) {
selected.value = i;
const cand = candidates.value[i];
if (cand) emit('pick', cand);
}
</script>
<template>
<div class="ext-card">
<div class="ext-title">{{ BF.extCandidatesTitle }}</div>
<div v-if="!candidates.length" class="ext-empty">{{ BF.extCandidatesEmpty }}</div>
<div
v-for="(c, i) in candidates"
:key="c.ext_id || i"
class="ext-row"
:class="{ selected: selected === i }"
role="button"
tabindex="0"
@click="pick(i)"
@keydown.enter.prevent="pick(i)"
>
<div class="ext-name">{{ c.display_name }}</div>
<div v-if="c.purpose" class="ext-purpose">{{ BF.extCandidatesPurposeLabel }}:{{ c.purpose }}</div>
</div>
</div>
</template>
<style scoped>
/* FlowPathCard 候选行令牌同款 */
.ext-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ext-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 10px;
}
.ext-empty {
padding: 10px 0;
font-size: 12px;
color: var(--pa-ink-subtle);
}
.ext-row {
padding: 8px 10px;
border-radius: var(--pa-r-md);
border: 1.5px solid transparent;
cursor: pointer;
transition: background 0.18s var(--pa-ease), border-color 0.18s var(--pa-ease);
}
.ext-row:hover {
background: var(--pa-surface-3);
}
.ext-row.selected {
border-color: var(--pa-g-from);
background: color-mix(in srgb, var(--pa-g-from) 8%, transparent);
}
.ext-name {
font-size: 12px;
font-weight: 600;
color: var(--pa-ink);
line-height: 1.6;
word-break: break-all;
}
.ext-purpose {
margin-top: 2px;
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
</style>
@@ -0,0 +1,208 @@
<script setup>
// chat/ExtensionConfirmCard.vue —— 运行前参数确认卡(EXTENSION_CONTRACT.md §4 extension_confirm 块)
// 参数确认(§5 运行时参数来源:用户句子提取值 > 确认卡手填值 > 参数 schema default;
// 本卡展示提取值并允许修改,提交时确认卡手填值覆盖提取值)。
// 提交 → chat.confirmExtensionRun(POST /extensions/{id}/run)→ 挂运行卡(run_started 形态)。
import { computed, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({
block: { type: Object, required: true },
message: { type: Object, default: null },
});
const emit = defineEmits(['submitted', 'error']);
const chat = useChatStore();
const submitting = ref(false);
const submitError = ref('');
const values = ref({});
const paramsSchema = computed(() => props.block.paramsSchema || []);
const extracted = computed(() => props.block.extractedParams || {});
const filesRequired = computed(() => props.block.filesRequired || []);
const hasSchema = computed(() => paramsSchema.value.length > 0);
function displayValue(f) {
if (f.name in values.value) return values.value[f.name];
const ex = extracted.value[f.name];
return ex != null && ex !== '' ? ex : (f.default ?? '');
}
function setValue(f, v) {
values.value[f.name] = v;
}
async function run() {
if (submitting.value) return;
submitting.value = true;
submitError.value = '';
try {
const params = {};
for (const f of paramsSchema.value) {
const v = displayValue(f);
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 params[f.name] = String(v);
}
const result = await chat.confirmExtensionRun(props.block, {
params,
files: [],
aMsg: props.message || null,
});
emit('submitted', { extId: props.block.extId, result });
} catch (err) {
submitError.value = err?.message || BF.extConfirmError;
emit('error', err);
} finally {
submitting.value = false;
}
}
</script>
<template>
<div class="ext-card">
<div class="ext-title">{{ BF.extConfirmTitle }}</div>
<div class="ext-muted">{{ BF.extConfirmHint }}</div>
<div v-if="filesRequired.length" class="ext-files">
<span class="ext-files-label">{{ BF.extConfirmFilesLabel }}</span>
<span class="ext-files-val">{{ filesRequired.join('、') }}</span>
</div>
<div v-for="f in paramsSchema" :key="f.name" class="ext-field">
<label class="ext-label">{{ f.name }}</label>
<div class="ext-field-body">
<input
:value="displayValue(f)"
class="ext-input"
type="text"
:disabled="submitting"
@input="(e) => setValue(f, e.target.value)"
/>
<div v-if="f.description" class="ext-hint">{{ f.description }}</div>
</div>
</div>
<div v-if="!hasSchema" class="ext-hint">{{ BF.extConfirmRun }}</div>
<div class="ext-actions">
<button class="ext-primary" type="button" :disabled="submitting || block.submitted" @click="run">
{{ submitting ? BF.extConfirmRunning : BF.extConfirmRun }}
</button>
</div>
<div v-if="submitError" class="ext-error">{{ submitError }}</div>
</div>
</template>
<style scoped>
/* FlowFormCard 字段行令牌同款 */
.ext-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ext-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 4px;
}
.ext-muted {
font-size: 12px;
color: var(--pa-ink-muted);
margin-bottom: 10px;
line-height: 1.6;
}
.ext-files {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 12px;
}
.ext-files-label {
color: var(--pa-ink-muted);
}
.ext-files-val {
font-family: var(--pa-font-mono);
color: var(--pa-ink);
}
.ext-field {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
}
.ext-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;
}
.ext-field-body {
flex: 1;
min-width: 0;
}
.ext-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);
}
.ext-input:focus {
border-color: var(--pa-g-from);
}
.ext-input:disabled {
opacity: 0.5;
}
.ext-hint {
font-size: 11px;
color: var(--pa-ink-subtle);
margin-top: 3px;
}
.ext-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.ext-primary {
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);
}
.ext-primary:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
.ext-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.ext-error {
margin-top: 8px;
font-size: 12px;
color: var(--pa-error);
}
</style>
@@ -0,0 +1,181 @@
<script setup>
// chat/ExtensionPublishCard.vue —— 发布卡(EXTENSION_CONTRACT.md §4 发布确认)
// nl_triggers 编辑(逗号分隔)+ 发布确认(POST /extensions/{id}/publish)。
// 发布成功后 extension_published 帧挂发布确认块(本卡随试运行卡同帧刷新)。
import { computed, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { publishExtension } from '../../api/bioflow.js';
const props = defineProps({
block: { type: Object, required: true },
});
const emit = defineEmits(['published', 'error']);
const submitting = ref(false);
const submitError = ref('');
const triggersDraft = ref('');
function syncDraft() {
triggersDraft.value = (props.block.nlTriggers || []).join(', ');
}
syncDraft();
const published = computed(() => !!props.block.published);
async function publish() {
if (submitting.value || published.value) return;
submitting.value = true;
submitError.value = '';
try {
const nlTriggers = triggersDraft.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
const data = await publishExtension(props.block.extId, { nl_triggers: nlTriggers });
const result = (data && data.data) || data || {};
if (Array.isArray(result.nl_triggers)) props.block.nlTriggers = result.nl_triggers;
else props.block.nlTriggers = nlTriggers;
if (result.display_name) props.block.displayName = result.display_name;
props.block.published = true;
emit('published', { extId: props.block.extId, nlTriggers: props.block.nlTriggers });
} catch (err) {
submitError.value = err?.message || BF.extPublishError;
emit('error', err);
} finally {
submitting.value = false;
}
}
</script>
<template>
<div class="ext-card">
<div class="ext-card-title">{{ BF.extPublishTitle }}</div>
<div class="ext-muted">{{ BF.extPublishHint }}</div>
<div class="ext-field">
<label class="ext-label">{{ BF.extPublishTriggersLabel }}</label>
<input
v-model="triggersDraft"
class="ext-input"
type="text"
:disabled="submitting || published"
/>
</div>
<div v-if="published" class="ext-published">
<div class="ext-published-title">{{ BF.extPublishedTitle }}</div>
<div class="ext-hint">{{ BF.extPublishedHint.replace('{name}', block.displayName || '') }}</div>
</div>
<div class="ext-actions">
<button class="ext-primary" type="button" :disabled="submitting || published" @click="publish">
{{ submitting ? BF.extPublishPublishing : BF.extPublishDo }}
</button>
</div>
<div v-if="submitError" class="ext-error">{{ submitError }}</div>
</div>
</template>
<style scoped>
/* FlowFormCard 字段行令牌同款 */
.ext-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ext-card-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 4px;
}
.ext-muted {
font-size: 12px;
color: var(--pa-ink-muted);
margin-bottom: 10px;
line-height: 1.6;
}
.ext-field {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
}
.ext-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;
}
.ext-input {
flex: 1;
min-width: 0;
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);
}
.ext-input:focus {
border-color: var(--pa-g-from);
}
.ext-input:disabled {
opacity: 0.5;
}
.ext-published {
padding: 8px 10px;
background: var(--pa-surface-2);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
}
.ext-published-title {
font-size: 12px;
font-weight: 600;
color: var(--pa-success);
}
.ext-hint {
font-size: 11px;
color: var(--pa-ink-subtle);
margin-top: 4px;
line-height: 1.6;
}
.ext-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.ext-primary {
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);
}
.ext-primary:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
.ext-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.ext-error {
margin-top: 8px;
font-size: 12px;
color: var(--pa-error);
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<script setup>
// chat/ExtensionRunCard.vue —— 扩展运行卡(EXTENSION_CONTRACT.md §4 extension_run_* 帧)
// 运行实时进度:run_started → run_log(日志尾逐帧追加)→ run_done(输出产物)/ run_failed。
// 复用 FlowProgressCard 结构令牌(badge 四态 + 日志尾区 --pa-code-bg)。
import { computed } from 'vue';
import { BF } from '../../i18n/bioflow.js';
const props = defineProps({
block: { type: Object, required: true },
});
const status = computed(() => props.block.status || 'created');
const outputs = computed(() => props.block.outputs || []);
const showLog = computed(() => status.value === 'running' || status.value === 'failed');
const badgeClass = computed(() => ({
running: status.value === 'running',
done: status.value === 'done',
error: status.value === 'failed',
idle: status.value === 'created',
}));
const badgeText = computed(() => ({
running: BF.extRunBadgeRunning,
done: BF.extRunBadgeDone,
failed: BF.extRunBadgeFailed,
created: BF.extRunBadgeCreated,
}[status.value] || BF.extRunBadgeCreated));
</script>
<template>
<div class="ext-card">
<div class="ext-head">
<span class="ext-title">{{ BF.extRunTitle }}</span>
<span class="badge" :class="badgeClass">
<span class="dot"></span>
{{ badgeText }}
</span>
</div>
<!-- 运行日志尾(run_log 帧逐帧追加;仅 running/failed 展开) -->
<div v-if="showLog" class="ext-log">
<div class="ext-log-head">
{{ BF.extRunLogLabel }}
<span v-if="block.exitCode != null" class="ext-exit">{{ BF.extRunExitCode }} {{ block.exitCode }}</span>
</div>
<pre class="ext-log-body">{{ block.log || block.logTail || BF.extRunLogPlaceholder }}</pre>
</div>
<!-- 失败错误 -->
<div v-if="status === 'failed' && block.error" class="ext-error">{{ block.error }}</div>
<!-- 输出产物(run_done) -->
<div v-if="status === 'done'" class="ext-outputs">
<div class="ext-outputs-head">{{ BF.extRunOutputsLabel }}</div>
<div v-if="outputs.length" class="ext-outputs-list">
<div v-for="(o, i) in outputs" :key="i" class="ext-output">
<span class="ext-output-name">{{ typeof o === 'string' ? o : (o.name || o.path || JSON.stringify(o)) }}</span>
</div>
</div>
<div v-else class="ext-hint">{{ BF.extRunOutputsEmpty }}</div>
</div>
</div>
</template>
<style scoped>
/* FlowProgressCard 结构令牌同款(badge 四态 + 日志尾区) */
.ext-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ext-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.ext-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--pa-r-pill);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.5px;
white-space: nowrap;
}
.badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.badge.running { color: var(--pa-warning); background: rgba(245, 184, 92, 0.12); }
.badge.running .dot { background: var(--pa-warning); animation: pulse 2s infinite; }
.badge.done { color: var(--pa-success); background: rgba(46, 211, 160, 0.12); }
.badge.done .dot { background: var(--pa-success); }
.badge.error { color: var(--pa-error); background: rgba(255, 92, 122, 0.12); }
.badge.error .dot { background: var(--pa-error); }
.badge.idle { color: var(--pa-ink-subtle); background: var(--pa-surface-2); }
.badge.idle .dot { background: var(--pa-ink-subtle); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.ext-log {
margin: 6px 0 2px;
}
.ext-log-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 600;
color: var(--pa-ink-muted);
margin-bottom: 4px;
}
.ext-exit {
font-family: var(--pa-font-mono);
font-weight: 400;
}
.ext-log-body {
margin: 0;
padding: 8px 10px;
background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
font-family: var(--pa-font-mono);
font-size: 11px;
line-height: 1.6;
color: var(--pa-ink-muted);
max-height: 96px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.ext-error {
margin-top: 8px;
font-size: 12px;
color: var(--pa-error);
}
.ext-outputs {
margin-top: 8px;
}
.ext-outputs-head {
font-size: 11px;
font-weight: 600;
color: var(--pa-ink-muted);
margin-bottom: 4px;
}
.ext-outputs-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.ext-output {
font-family: var(--pa-font-mono);
font-size: 12px;
color: var(--pa-ink);
}
.ext-output-name {
word-break: break-all;
}
.ext-hint {
font-size: 11px;
color: var(--pa-ink-subtle);
}
</style>
@@ -0,0 +1,260 @@
<script setup>
// chat/ExtensionTrialCard.vue —— 试运行卡(EXTENSION_CONTRACT.md §4 extension_trial/repair 块)
// 测试输入文件上传(可选金标输出)+ 运行按钮 + 实时日志尾(extension_trial 帧
// 逐帧追加 logTail)+ 失败展示 AI 修复建议与「AI 修复」按钮(POST /extensions/{id}/repair)。
// 日志区复用 FlowProgressCard 日志尾令牌(--pa-code-bg mono 11px max-height 96px 滚动)。
import { computed, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
import { repairExtension } from '../../api/bioflow.js';
const props = defineProps({
block: { type: Object, required: true },
});
const emit = defineEmits(['trialled', 'error']);
const chat = useChatStore();
const testInput = ref(null); // 测试输入 File
const goldenInput = ref(null); // 可选金标 File
const submitError = ref('');
const running = ref(false);
const repairing = ref(false);
const status = computed(() => props.block.status || 'running');
const failed = computed(() => status.value === 'failed');
const done = computed(() => status.value === 'done');
const isRepairing = computed(() => status.value === 'repairing' || repairing.value);
function onTestChange(e) {
testInput.value = (e.target.files && e.target.files[0]) || null;
if (e.target) e.target.value = '';
}
function onGoldenChange(e) {
goldenInput.value = (e.target.files && e.target.files[0]) || null;
if (e.target) e.target.value = '';
}
/** 上传测试输入 → 触发试运行(服务端跑容器,结果经 extension_trial 帧回流) */
async function runTrial() {
if (running.value || repairing.value) return;
if (!props.block.extId) { submitError.value = BF.extTrialNoExtId; return; }
if (!testInput.value) { submitError.value = BF.extTrialInputEmpty; return; }
running.value = true;
submitError.value = '';
try {
const files = [testInput.value];
if (goldenInput.value) files.push(goldenInput.value);
const result = await chat.trialRunExtension(props.block.extId, files);
emit('trialled', { extId: props.block.extId, result });
} catch (err) {
submitError.value = err?.message || BF.extUploadError;
emit('error', err);
} finally {
running.value = false;
}
}
/** AI 修复(§4 extension_repair 帧驱动状态与建议;请求触发服务端修复循环) */
async function doRepair() {
if (repairing.value || running.value) return;
if (!props.block.extId) { submitError.value = BF.extTrialNoExtId; return; }
repairing.value = true;
submitError.value = '';
try {
await repairExtension(props.block.extId, {});
} catch (err) {
submitError.value = err?.message || BF.extTrialRepairFailed;
emit('error', err);
} finally {
repairing.value = false;
}
}
</script>
<template>
<div class="ext-card">
<div class="ext-card-title">
{{ BF.extTrialTitle }}
<span class="ext-badge" :class="failed ? 'error' : done ? 'done' : 'running'">
{{ failed ? BF.extTrialFailed : done ? BF.extTrialDone : (isRepairing ? BF.extTrialRepairing : BF.extTrialRunning) }}
</span>
</div>
<!-- 测试输入上传行 -->
<div class="ext-trial-row">
<input type="file" class="ext-trial-file" :disabled="running || repairing" @change="onTestChange" />
<input type="file" class="ext-trial-file" :disabled="running || repairing" @change="onGoldenChange" />
<span v-if="testInput" class="ext-hint">{{ testInput.name }}</span>
<button class="ext-primary" type="button" :disabled="running || repairing || done" @click="runTrial">
{{ running ? BF.extTrialRunning : (failed ? BF.extTrialRerun : BF.extTrialRun) }}
</button>
</div>
<div class="ext-hint">{{ BF.extTrialTestHint }}</div>
<!-- 实时日志尾(extension_trial 帧 log_tail 逐帧追加) -->
<div class="ext-log">
<div class="ext-log-head">
{{ BF.extTrialLogLabel }}
<span v-if="block.exitCode != null" class="ext-exit">{{ BF.extTrialExitCode }} {{ block.exitCode }}</span>
</div>
<pre class="ext-log-body">{{ block.logTail || BF.extTrialLogPlaceholder }}</pre>
</div>
<!-- 失败:错误 + AI 修复建议与按钮(§4 repair 循环同卡刷新) -->
<template v-if="failed || isRepairing || block.suggestion">
<div v-if="block.error" class="ext-error">{{ block.error }}</div>
<div v-if="block.suggestion" class="ext-suggestion">
<span class="ext-suggestion-label">{{ BF.extTrialRepairSuggestionLabel }}</span>
<span class="ext-suggestion-text">{{ block.suggestion }}</span>
</div>
<div class="ext-repair-row">
<button class="ext-primary" type="button" :disabled="repairing || running" @click="doRepair">
{{ repairing ? BF.extTrialRepairing : BF.extTrialRepairBtn }}
</button>
<span v-if="block.repairCount != null" class="ext-hint">
{{ BF.extTrialRepairCount.replace('{n}', block.repairCount) }}
</span>
</div>
</template>
<div v-if="submitError" class="ext-error">{{ submitError }}</div>
</div>
</template>
<style scoped>
/* FlowProgressCard 日志尾令牌同款 */
.ext-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ext-card-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.ext-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--pa-r-pill);
}
.ext-badge.running {
color: var(--pa-warning);
background: rgba(245, 184, 92, 0.12);
}
.ext-badge.done {
color: var(--pa-success);
background: rgba(46, 211, 160, 0.12);
}
.ext-badge.error {
color: var(--pa-error);
background: rgba(255, 92, 122, 0.12);
}
.ext-trial-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ext-trial-file {
font-size: 12px;
color: var(--pa-ink-muted);
}
.ext-hint {
font-size: 11px;
color: var(--pa-ink-subtle);
margin-top: 4px;
}
.ext-log {
margin: 10px 0 2px;
}
.ext-log-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 600;
color: var(--pa-ink-muted);
margin-bottom: 4px;
}
.ext-exit {
font-family: var(--pa-font-mono);
font-weight: 400;
}
.ext-log-body {
margin: 0;
padding: 8px 10px;
background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
font-family: var(--pa-font-mono);
font-size: 11px;
line-height: 1.6;
color: var(--pa-ink-muted);
max-height: 96px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.ext-error {
margin-top: 8px;
font-size: 12px;
color: var(--pa-error);
}
.ext-suggestion {
margin-top: 8px;
padding: 8px 10px;
background: var(--pa-surface-2);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
display: flex;
flex-direction: column;
gap: 4px;
}
.ext-suggestion-label {
font-size: 11px;
font-weight: 600;
color: var(--pa-ink-muted);
}
.ext-suggestion-text {
font-size: 12px;
color: var(--pa-ink-muted);
line-height: 1.6;
word-break: break-all;
}
.ext-repair-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.ext-primary {
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);
}
.ext-primary:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
.ext-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
</style>
@@ -0,0 +1,388 @@
<script setup>
// chat/ExtensionUploadCard.vue —— 扩展上传引导卡(EXTENSION_CONTRACT.md §4 extension_upload 块)
// zip 拖拽/选择 + 名称 + 运行命令 + base 镜像选择(8 镜像下拉) + 可选金标输出。
// 复用 FlowFileCard/FlowFormCard 视觉令牌(EXT_CARD 共享令牌 + 拖放区/进度条同款)。
// 提交 → chat.uploadExtensionFile(FormData multipart → POST /api/bioflow/extensions),
// 结果 patch 回块(extId/status);创建失败可见可重试。
import { computed, ref } from 'vue';
import { BF, EXT_BASE_IMAGES } from '../../i18n/bioflow.js';
import { useChatStore } from '../../stores/chat.js';
const props = defineProps({
block: { type: Object, required: true },
});
const emit = defineEmits(['uploaded', 'error']);
const chat = useChatStore();
const dropActive = ref(false);
const fileInput = ref(null);
const goldenInput = ref(null);
const submitError = ref('');
const submitting = ref(false);
const zip = ref(null); // 选择的 File(zip)
const golden = ref(null); // 可选金标 File
const displayName = ref('');
const command = ref('');
const runtimeImage = ref('bioflow-gf');
const files = computed(() => props.block.files || []);
const uploading = computed(() => files.value.some((f) => f.status === 'uploading'));
const created = computed(() => !!props.block.extId && props.block.status === 'created');
const noMatch = computed(() => props.block.noMatch || null);
function acceptZip(f) {
const name = String((f && f.name) || '').toLowerCase();
return name.endsWith('.zip');
}
// ---- 拖放(dragover 高亮 → drop 选择;已创建/上传中忽略) ----
const onDragOver = (e) => {
if (created.value || uploading.value) return;
const types = Array.from(e.dataTransfer?.types || []);
if (types.includes('Files')) {
e.preventDefault();
dropActive.value = true;
}
};
const onDragLeave = () => { dropActive.value = false; };
const onDrop = (e) => {
e.preventDefault();
dropActive.value = false;
if (created.value || uploading.value) return;
const list = e.dataTransfer ? Array.from(e.dataTransfer.files) : [];
const z = list.find((f) => acceptZip(f)) || null;
if (z) {
zip.value = z;
if (!displayName.value) {
displayName.value = z.name.replace(/\.zip$/i, '');
}
submitError.value = '';
} else {
submitError.value = BF.extUploadZipRequired;
}
};
function onClickPick() {
if (created.value || uploading.value) return;
if (fileInput.value) fileInput.value.click();
}
function onInputChange(e) {
const f = e.target.files && e.target.files[0];
if (f) {
if (acceptZip(f)) {
zip.value = f;
if (!displayName.value) displayName.value = f.name.replace(/\.zip$/i, '');
} else {
submitError.value = BF.extUploadZipRequired;
}
}
if (e.target) e.target.value = ''; // 允许重复选择同一文件
}
function onGoldenChange(e) {
golden.value = (e.target.files && e.target.files[0]) || null;
}
async function submit() {
if (submitting.value || created.value || uploading.value) return;
submitError.value = '';
if (!zip.value) { submitError.value = BF.extUploadZipRequired; return; }
if (!displayName.value.trim()) { submitError.value = BF.extUploadNameRequired; return; }
if (!command.value.trim()) { submitError.value = BF.extUploadCommandRequired; return; }
submitting.value = true;
try {
const result = await chat.uploadExtensionFile(props.block, {
zip: zip.value,
displayName: displayName.value,
command: command.value,
runtimeImage: runtimeImage.value,
golden: golden.value || null,
}, {
onProgress: (pct, item) => {
const rec = props.block.files.find((x) => x.name === item.name && x.status === 'uploading');
if (rec) rec.progress = pct;
},
});
props.block.displayName = displayName.value.trim();
props.block.command = command.value.trim();
props.block.runtimeImage = runtimeImage.value;
emit('uploaded', { name: zip.value.name, res: result });
} catch (err) {
submitError.value = err?.message || BF.extUploadError;
emit('error', err);
} finally {
submitting.value = false;
}
}
</script>
<template>
<div class="ext-card">
<div class="ext-card-title">{{ BF.extUploadTitle }}</div>
<div class="ext-card-muted" v-if="noMatch">{{ BF.extUploadNoMatchPrefix }}</div>
<div class="ext-card-muted" v-else>{{ BF.extUploadPrefix }}</div>
<!-- 拖放区(FlowFileCard §8.3 同款令牌) -->
<div
class="ext-drop"
:class="{ active: dropActive, busy: uploading || created }"
role="button"
tabindex="0"
@click="onClickPick"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@keydown.enter.prevent="onClickPick"
>
<div class="ext-drop-hint">{{ uploading ? BF.extUploadBusy : BF.extUploadDropHint }}</div>
<input ref="fileInput" type="file" class="ext-input-hidden" accept=".zip" @change="onInputChange" />
</div>
<!-- 上传记录(进度复用 prog-track/prog-fill) -->
<div v-if="files.length" class="ext-file-list">
<div v-for="(f, i) in files" :key="i" class="ext-file-item">
<span class="ext-file-name" :class="f.status">{{ f.name }}</span>
<span v-if="f.status === 'uploading'" class="ext-file-progress">
<span class="prog-track"><span class="prog-fill" :style="{ width: Math.round((f.progress || 0) * 100) + '%' }"></span></span>
<span class="ext-pct">{{ Math.round((f.progress || 0) * 100) }}%</span>
</span>
<span v-else-if="f.status === 'done'" class="ext-file-done">{{ BF.extUploadDone }}</span>
<span v-else class="ext-file-err" :title="f.error">{{ BF.extUploadError }}</span>
</div>
</div>
<!-- 名称 / 运行命令 / 镜像 / 金标 -->
<div class="ext-field">
<label class="ext-label">{{ BF.extUploadNameLabel }}</label>
<input v-model="displayName" class="ext-input" type="text" :placeholder="BF.extUploadNamePlaceholder" :disabled="submitting || created" />
</div>
<div class="ext-field">
<label class="ext-label">{{ BF.extUploadCommandLabel }}</label>
<input v-model="command" class="ext-input" type="text" :placeholder="BF.extUploadCommandPlaceholder" :disabled="submitting || created" />
</div>
<div class="ext-field">
<label class="ext-label">{{ BF.extUploadImageLabel }}</label>
<div class="ext-field-body">
<select v-model="runtimeImage" class="ext-input" :disabled="submitting || created">
<option v-for="img in EXT_BASE_IMAGES" :key="img.value" :value="img.value">{{ img.label }}</option>
</select>
<div class="ext-hint">{{ BF.extUploadImageHint }}</div>
</div>
</div>
<div class="ext-field">
<label class="ext-label">{{ BF.extUploadGoldenLabel }}</label>
<div class="ext-field-body">
<input
type="file"
class="ext-golden-file"
:disabled="submitting || created"
@change="onGoldenChange"
/>
<span v-if="golden" class="ext-hint">{{ golden.name }}</span>
</div>
</div>
<div v-if="created" class="ext-created">{{ BF.extUploadCreated }}</div>
<div class="ext-actions">
<button
class="ext-primary"
type="button"
:disabled="!zip || submitting || created || uploading"
@click="submit"
>{{ submitting ? BF.extUploadSubmitting : BF.extUploadSubmit }}</button>
</div>
<div v-if="submitError" class="ext-error">{{ submitError }}</div>
</div>
</template>
<style scoped>
/* EXT_CARD 令牌展开(scoped 兜底) */
.ext-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ext-card-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 4px;
}
.ext-card-muted {
font-size: 12px;
color: var(--pa-ink-muted);
margin-bottom: 10px;
line-height: 1.6;
}
.ext-drop {
border: 2px dashed var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
padding: 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.18s var(--pa-ease), background 0.18s var(--pa-ease);
}
.ext-drop.active {
border-color: color-mix(in srgb, var(--pa-g-from) 45%, transparent);
background: color-mix(in srgb, var(--pa-g-from) 8%, transparent);
}
.ext-drop.busy {
cursor: not-allowed;
opacity: 0.6;
}
.ext-drop-hint {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.ext-input-hidden {
display: none;
}
.ext-file-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.ext-file-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.ext-file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--pa-ink-muted);
font-family: var(--pa-font-mono);
}
.ext-file-name.done { color: var(--pa-ink); }
.ext-file-name.error { color: var(--pa-error); }
.ext-file-progress {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
width: 120px;
}
.prog-track {
flex: 1;
height: 4px;
border-radius: var(--pa-r-pill);
background: var(--pa-surface-2);
overflow: hidden;
}
.prog-fill {
display: block;
height: 100%;
background: var(--pa-gradient);
transition: width 0.15s var(--pa-ease);
}
.ext-pct {
font-size: 11px;
font-family: var(--pa-font-mono);
color: var(--pa-ink-subtle);
min-width: 34px;
text-align: right;
}
.ext-file-done {
flex: none;
font-size: 11px;
color: var(--pa-success);
}
.ext-file-err {
flex: none;
font-size: 11px;
color: var(--pa-error);
}
.ext-field {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 10px;
}
.ext-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;
}
.ext-field-body {
flex: 1;
min-width: 0;
}
.ext-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);
}
.ext-input:focus {
border-color: var(--pa-g-from);
}
.ext-input:disabled {
opacity: 0.5;
}
.ext-hint {
display: block;
margin-top: 3px;
font-size: 11px;
color: var(--pa-ink-subtle);
}
.ext-golden-file {
font-size: 12px;
color: var(--pa-ink-muted);
}
.ext-created {
margin-top: 10px;
font-size: 12px;
color: var(--pa-success);
}
.ext-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.ext-primary {
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);
}
.ext-primary:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
.ext-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.ext-error {
margin-top: 8px;
font-size: 12px;
color: var(--pa-error);
}
</style>
@@ -0,0 +1,200 @@
<script setup>
// chat/ExtensionValidateCard.vue —— 金标/确认卡(EXTENSION_CONTRACT.md §4 extension_validate 块)
// 有金标时展示对比结果;参数确认(form_defaults 可编辑,与 stage_await_confirm 同门禁纪律)。
// 复用 FlowFormCard 字段行令牌。确认成功后由服务端 extension_published 帧收尾。
import { computed, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { buildExtension } from '../../api/bioflow.js';
const props = defineProps({
block: { type: Object, required: true },
});
const emit = defineEmits(['submitted', 'error']);
const submitting = ref(false);
const submitError = ref('');
const values = ref({});
const golden = computed(() => props.block.golden || null);
const formDefaults = computed(() => props.block.formDefaults || []);
function syncDefaults() {
for (const f of formDefaults.value) {
if (!(f.name in values.value)) values.value[f.name] = f.default ?? '';
}
}
syncDefaults();
const goldenOk = computed(() => {
const g = golden.value;
if (!g) return null; // 无金标
if (typeof g === 'boolean') return g;
if (g && typeof g === 'object') {
if (g.ok != null) return !!g.ok;
if (g.matches != null) return !!g.matches;
}
return null;
});
/** 确认:重新 build(门禁纪律:确认前不推进生命周期;修正值随 build 重入)。 */
async function confirm() {
if (submitting.value) return;
submitting.value = true;
submitError.value = '';
try {
const params = {};
for (const f of formDefaults.value) {
const v = values.value[f.name];
if (v === '' || v === null || v === undefined) continue;
params[f.name] = v;
}
await buildExtension(props.block.extId, { params });
props.block.confirmed = true;
emit('submitted', { extId: props.block.extId, params });
} catch (err) {
submitError.value = err?.message || BF.extValidateError;
emit('error', err);
} finally {
submitting.value = false;
}
}
</script>
<template>
<div class="ext-card">
<div class="ext-card-title">{{ BF.extValidateTitle }}</div>
<!-- 金标对比结果 -->
<div class="ext-golden">
<span class="ext-golden-label">{{ BF.extValidateGoldenLabel }}</span>
<span v-if="goldenOk === true" class="ext-golden-ok">✓</span>
<span v-else-if="goldenOk === false" class="ext-golden-fail">✕</span>
<span v-else class="ext-hint">{{ BF.extValidateGoldenNone }}</span>
</div>
<!-- 参数确认(form_defaults 可编辑;与 stage_await_confirm 同门禁纪律) -->
<div v-for="f in formDefaults" :key="f.name" class="ext-field">
<label class="ext-label">{{ f.name }}</label>
<div class="ext-field-body">
<input v-model="values[f.name]" class="ext-input" type="text" :disabled="submitting" />
<div v-if="f.description" class="ext-hint">{{ f.description }}</div>
</div>
</div>
<div class="ext-actions">
<button class="ext-primary" type="button" :disabled="submitting || block.confirmed" @click="confirm">
{{ submitting ? BF.extValidateConfirming : BF.extValidateConfirm }}
</button>
</div>
<div v-if="submitError" class="ext-error">{{ submitError }}</div>
</div>
</template>
<style scoped>
/* FlowFormCard §8.2 字段行令牌同款 */
.ext-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.ext-card-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 8px;
}
.ext-golden {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 12px;
}
.ext-golden-label {
color: var(--pa-ink-muted);
}
.ext-golden-ok {
color: var(--pa-success);
font-weight: 600;
}
.ext-golden-fail {
color: var(--pa-error);
font-weight: 600;
}
.ext-field {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
}
.ext-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;
}
.ext-field-body {
flex: 1;
min-width: 0;
}
.ext-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);
}
.ext-input:focus {
border-color: var(--pa-g-from);
}
.ext-input:disabled {
opacity: 0.5;
}
.ext-hint {
font-size: 11px;
color: var(--pa-ink-subtle);
}
.ext-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.ext-primary {
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);
}
.ext-primary:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
.ext-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.ext-error {
margin-top: 8px;
font-size: 12px;
color: var(--pa-error);
}
</style>
@@ -0,0 +1,338 @@
<script setup>
// session/ExtensionsPane.vue —— 侧栏「扩展」入口面板(dev/extension-frontend 归属)
// 仅查看:已发布扩展列表 / 单扩展运行历史 / 删除。创建流程全在对话引导卡内完成
// (方案 C:扩展管理完全融入对话流)。视觉复用 BioJobsPane 的空态/错误/重试三态纪律。
import { computed, onMounted, ref } from 'vue';
import { BF } from '../../i18n/bioflow.js';
import { useExtensionsStore } from '../../stores/extensions.js';
defineProps({
open: { type: Boolean, default: false },
});
const emit = defineEmits(['close']);
const ext = useExtensionsStore();
const confirmDelete = ref(null); // {id, name}
const deleting = ref(false);
const runsOpen = ref({}); // extId -> true(运行历史展开)
onMounted(() => {
ext.load().catch(() => { /* 失败置 error 态(失败必须可见) */ });
});
const statusText = (s) => (s === 'published' ? BF.extPaneStatusPublished : BF.extPaneStatusDraft);
const runsOf = (e) => (ext.runs[(e.id || e.ext_id)] || {}).data || [];
const runsLoadingOf = (e) => !!ext.runsLoading[(e.id || e.ext_id)];
function toggleRuns(e) {
const key = e.id || e.ext_id;
const opened = !runsOpen.value[key];
runsOpen.value = { ...runsOpen.value, [key]: opened };
if (opened) {
ext.loadRuns(key).catch(() => { /* 失败可见:runs 记录 error */ });
}
}
function askDelete(e) {
confirmDelete.value = { id: e.id || e.ext_id, name: e.display_name || e.name || e.id };
}
function cancelDelete() {
confirmDelete.value = null;
}
async function doDelete() {
if (deleting.value || !confirmDelete.value) return;
deleting.value = true;
try {
await ext.remove(confirmDelete.value.id);
confirmDelete.value = null;
} catch {
/* 删除失败:列表未变,可重试(错误可见性由列表态保证) */
} finally {
deleting.value = false;
}
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="ext-modal">
<div class="ext-modal-box">
<div class="ext-modal-head">
<span class="ext-modal-title">{{ BF.extPaneTitle }}</span>
<button class="icon-btn" type="button" :title="BF.jobsConfirmNo" @click="emit('close')">✕</button>
</div>
<div class="ext-modal-body">
<div v-if="ext.loading" class="ext-hint">{{ BF.extPaneLoading }}</div>
<div v-else-if="ext.error" class="ext-error-row">
<span class="ext-error">{{ BF.extPaneLoadError }}</span>
<button class="pane-btn" type="button" @click="ext.load({ force: true })">{{ BF.extPaneReload }}</button>
</div>
<div v-else-if="!ext.items.length" class="ext-empty">{{ BF.extPaneEmpty }}</div>
<div v-else class="ext-list">
<div v-for="e in ext.items" :key="e.id || e.ext_id" class="ext-item">
<div class="ext-item-head">
<span class="ext-item-name">{{ e.display_name || e.name || e.id }}</span>
<span class="ext-item-status" :class="{ pub: e.status === 'published' }">{{ statusText(e.status) }}</span>
<button class="pane-btn" type="button" @click="toggleRuns(e)">{{ BF.extPaneRuns }}</button>
<button class="pane-btn danger" type="button" :disabled="deleting" @click="askDelete(e)">{{ BF.extPaneDelete }}</button>
</div>
<div v-if="e.nl_triggers && e.nl_triggers.length" class="ext-item-triggers">
{{ BF.extPublishTriggersLabel }}:{{ e.nl_triggers.join('、') }}
</div>
<div v-if="runsOpen[e.id || e.ext_id]" class="ext-runs">
<div v-if="runsLoadingOf(e)" class="ext-hint">{{ BF.extPaneRunsLoading }}</div>
<div v-else-if="runsOf(e).length" class="ext-runs-list">
<div class="ext-hint">{{ BF.extPaneRunsCount.replace('{n}', runsOf(e).length) }}</div>
<div v-for="(r, i) in runsOf(e)" :key="r.run_id || i" class="ext-run">
<span class="ext-run-id">{{ r.run_id }}</span>
<span class="ext-run-status">{{ r.status || '-' }}</span>
</div>
</div>
<div v-else class="ext-hint">{{ BF.extPaneRunsEmpty }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="open && confirmDelete" class="confirm-mask">
<div class="confirm-box">
<div class="cb-title">{{ BF.extPaneDeleteConfirm.replace('{name}', confirmDelete.name) }}</div>
<div class="cb-actions">
<button class="cb-btn ghost" type="button" @click="cancelDelete">{{ BF.extPaneConfirmNo }}</button>
<button class="cb-btn danger" type="button" :disabled="deleting" @click="doDelete">{{ BF.extPaneConfirmYes }}</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
/* 中央模态(SettingsPanel 同款形态)+ BioJobsPane 空态/错误三态纪律 */
.ext-modal {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(10, 10, 16, 0.5);
backdrop-filter: blur(6px);
}
.ext-modal-box {
width: min(560px, 92vw);
max-height: 76vh;
display: flex;
flex-direction: column;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
overflow: hidden;
}
.ext-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--pa-hairline);
}
.ext-modal-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.3px;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--pa-r-md);
border: 1px solid var(--pa-hairline);
background: var(--pa-surface-1);
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
}
.icon-btn:hover {
color: var(--pa-ink);
background: var(--pa-surface-3);
}
.ext-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 16px;
}
.ext-hint {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.ext-error {
font-size: 12px;
color: var(--pa-error);
}
.ext-error-row {
display: flex;
align-items: center;
gap: 10px;
}
.ext-empty {
padding: 12px 0;
font-size: 12px;
color: var(--pa-ink-subtle);
line-height: 1.7;
}
.ext-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.ext-item {
padding: 10px 12px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: var(--pa-surface-2);
}
.ext-item-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ext-item-name {
flex: 1;
min-width: 0;
font-size: 12.5px;
font-weight: 600;
color: var(--pa-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext-item-status {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--pa-r-pill);
color: var(--pa-ink-subtle);
background: var(--pa-surface-3);
}
.ext-item-status.pub {
color: var(--pa-success);
background: rgba(46, 211, 160, 0.12);
}
.ext-item-triggers {
margin-top: 6px;
font-size: 11.5px;
color: var(--pa-ink-subtle);
line-height: 1.6;
word-break: break-all;
}
.pane-btn {
padding: 5px 12px;
border-radius: var(--pa-r-md);
border: 1px solid var(--pa-hairline);
background: var(--pa-surface-1);
color: var(--pa-ink-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.18s var(--pa-ease);
}
.pane-btn:hover:not(:disabled) {
color: var(--pa-ink);
background: var(--pa-surface-3);
}
.pane-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pane-btn.danger:hover:not(:disabled) {
color: var(--pa-error);
}
.ext-runs {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--pa-hairline);
}
.ext-runs-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.ext-run {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.ext-run-id {
font-family: var(--pa-font-mono);
color: var(--pa-ink);
}
.ext-run-status {
font-family: var(--pa-font-mono);
color: var(--pa-ink-subtle);
}
/* 删除二次确认(SessionPanel 同款 confirm-mask/cb-btn) */
.confirm-mask {
position: fixed;
inset: 0;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
background: rgba(10, 10, 16, 0.5);
}
.confirm-box {
width: 320px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.cb-title {
font-size: 13px;
color: var(--pa-ink);
line-height: 1.6;
word-break: break-all;
}
.cb-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.cb-btn {
padding: 6px 14px;
border-radius: var(--pa-r-md);
border: 1px solid var(--pa-hairline);
background: var(--pa-surface-2);
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
}
.cb-btn.ghost:hover {
color: var(--pa-ink);
}
.cb-btn.danger {
background: var(--pa-error);
border-color: transparent;
color: #fff;
}
.cb-btn.danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+10 -1
View File
@@ -3,18 +3,23 @@
// 新建按钮 / 搜索框 / 分组列表(今天/昨天/更早)/ 状态点四态 / 活动渐变条 /
// 重命名双击 / 删除二次确认 / 空搜索提示(FR-SESSION-001~009)。
// 注意:左抽屉形态由 B1 布局层负责;本组件只渲染面板内容(session-panel 根元素)。
import { computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useSessionsStore } from '../../stores/sessions.js';
import { useChatStore } from '../../stores/chat.js';
import { T, SESSION_GROUPS } from '../../i18n/labels.js';
import SearchBox from './SearchBox.vue';
import SessionItem from './SessionItem.vue';
import SessionRail from './SessionRail.vue';
// 用户自定义扩展分析:侧栏「扩展」入口(仅列表/历史/删除;创建在对话引导卡)
import ExtensionsPane from './ExtensionsPane.vue';
const emit = defineEmits(['open-rail', 'goto-kg', 'goto-settings']);
const sessions = useSessionsStore();
const chat = useChatStore();
const confirmDelete = computed(() => sessions._confirmDelete || null);
const extensionsOpen = ref(false);
onMounted(() => {
sessions.load().catch(() => {
@@ -145,10 +150,14 @@ const doDelete = async () => {
<SessionRail
:active="true"
@open="$emit('open-rail')"
@goto-extensions="extensionsOpen = true"
@goto-kg="$emit('goto-kg')"
@goto-settings="$emit('goto-settings')"
/>
<!-- 用户自定义扩展分析侧栏管理(仅列表/历史/删除) -->
<ExtensionsPane :open="extensionsOpen" @close="extensionsOpen = false" />
<!-- 删除二次确认(FR-SESSION-007)
Teleport 到 body:SessionPanel 在平板档渲染于 B1 的 Drawer 内,
.drawer-el 的 transform 会使内部 position:fixed 相对该祖先定位(而非视口),
+10 -2
View File
@@ -1,13 +1,15 @@
<script setup>
// session/SessionRail.vue —— 左栏折叠后的 64px 图标轨(FR-SHELL-004 的 B2 侧)
// 会话/图谱/设置三个 rail-item;会话项当前,带左侧渐变竖条。
// 会话/扩展/图谱/设置四个 rail-item;会话项当前,带左侧渐变竖条。
// 「扩展」入口 = 用户自定义扩展分析侧栏管理(仅列表/历史/删除,创建在对话引导卡)。
import { BF } from '../../i18n/bioflow.js';
import { T } from '../../i18n/labels.js';
defineProps({
active: { type: Boolean, default: true },
});
const emit = defineEmits(['open', 'goto-kg', 'goto-settings']);
const emit = defineEmits(['open', 'goto-extensions', 'goto-kg', 'goto-settings']);
</script>
<template>
@@ -17,6 +19,12 @@ const emit = defineEmits(['open', 'goto-kg', 'goto-settings']);
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
<button class="rail-item" :title="BF.extRailTitle" type="button" @click="emit('goto-extensions')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4L4.2 7.7l5.4-.8z" />
<path d="M20 14v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-5" />
</svg>
</button>
<button class="rail-item" :title="T.railKg" type="button" @click="emit('goto-kg')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
+133
View File
@@ -176,8 +176,141 @@ export const BF = {
stageStatusSkipped: '已跳过',
stageStatusPaused: '已暂停',
stageStatusUnknown: '未知',
// ---- 用户自定义扩展分析(EXTENSION_CONTRACT.md §4 引导卡文案) ----
// 上传引导卡(extension_upload 块)
extUploadTitle: '🧩 扩展上传引导',
extUploadPrefix: '请上传扩展脚本 zip 包,并填写名称与运行命令。zip 内含入口脚本(如 quality_stats.py)与依赖文件。',
extUploadDropHint: '将 zip 包拖入此处上传,或点击选择文件',
extUploadBusy: '上传中…',
extUploadNameLabel: '名称',
extUploadNamePlaceholder: '如:测序质量统计',
extUploadCommandLabel: '运行命令',
extUploadCommandPlaceholder: '如:python quality_stats.py --fastq input.fastq --out results/',
extUploadImageLabel: '运行镜像',
extUploadImageHint: '8 个 base 镜像供选择(默认 bioflow-gf 工具链最全)',
extUploadGoldenLabel: '金标输出(可选)',
extUploadSubmit: '上传并创建',
extUploadSubmitting: '创建中…',
extUploadError: '上传失败',
extUploadCreated: '扩展已创建',
extUploadNoMatchPrefix: '没有匹配的已发布扩展,是否创建新扩展?',
extUploadNameRequired: '请填写名称',
extUploadCommandRequired: '请填写运行命令',
extUploadZipRequired: '请先选择 zip 包',
extUploadDone: '已上传',
// AI 转换卡(extension_build 块)
extBuildTitle: '🧩 AI 转换 · 命令模板与依赖',
extBuildStatus: '转换中…',
extBuildStatusFailed: '转换失败',
extBuildTemplateLabel: '命令模板',
extBuildTemplateHint: '占位符白名单:{code_dir} {input_dir} {input_file} {input_hmm} {input_fasta} {input_query_fasta} {input_reference_fasta} {input_pep_fasta} {input_cds_fasta} {input_id_file} {output_dir} {work_dir} + {param_<name>};{output_dir} 必含。',
extBuildDepsLabel: '依赖',
extBuildDepsEmpty: '纯标准库,无额外依赖',
extBuildPipLabel: 'pip',
extBuildAptLabel: 'apt',
extBuildImageLabel: '推荐镜像',
extBuildTriggersLabel: '自然语言触发词',
extBuildTriggersHint: '逗号分隔;发布后用户可用这些短语触发运行',
extBuildTypeLabel: '分析类型',
extBuildParamsLabel: '参数',
extBuildParamsEmpty: '无需参数',
extBuildSubmit: '提交修正',
extBuildSubmitting: '提交中…',
extBuildSkip: '跳过(使用 AI 结果)',
extBuildError: '提交失败',
extBuildFieldRequired: '必填不能为空',
// 试运行卡(extension_trial 块)
extTrialTitle: '🧩 试运行',
extTrialRunning: '试运行中…',
extTrialDone: '试运行通过',
extTrialFailed: '试运行失败',
extTrialRepairing: 'AI 修复中…',
extTrialLogLabel: '日志尾',
extTrialLogPlaceholder: '暂无日志',
extTrialRun: '运行试运行',
extTrialRerun: '重新试运行',
extTrialTestHint: '上传测试输入文件(可选:金标输出用于对比验证)',
extTrialRepairBtn: 'AI 修复',
extTrialRepairSuggestionLabel: '修复建议',
extTrialRepairCount: '已修复 {n} 轮',
extTrialRepairFailed: '修复失败',
extTrialExitCode: '退出码',
extTrialInputEmpty: '请先选择测试输入文件',
extTrialNoExtId: '扩展未创建,无法试运行',
// 金标/确认卡(extension_validate 块)
extValidateTitle: '🧩 金标验证 · 参数确认',
extValidateGoldenLabel: '金标对比结果',
extValidateGoldenNone: '未提供金标数据',
extValidateConfirm: '确认',
extValidateConfirming: '确认中…',
extValidateError: '确认失败',
// 发布卡(extension_publish 块)
extPublishTitle: '🧩 发布扩展',
extPublishHint: '发布后该扩展可被自然语言触发。触发词可编辑(逗号分隔)。',
extPublishTriggersLabel: '触发词',
extPublishDo: '确认发布',
extPublishPublishing: '发布中…',
extPublishError: '发布失败',
extPublishedTitle: '扩展已发布',
extPublishedHint: '之后可在对话中说「帮我用 {name} 分析…」触发运行',
// 候选卡(extension_candidates 块)
extCandidatesTitle: '🧩 匹配到多个扩展,请选择',
extCandidatesPurposeLabel: '用途',
extCandidatesEmpty: '暂无候选扩展',
// 参数确认卡(extension_confirm 块)
extConfirmTitle: '🧩 运行参数确认',
extConfirmHint: '将运行该扩展,请确认输入参数(可直接修改)',
extConfirmFilesLabel: '需要输入文件',
extConfirmRun: '确认运行',
extConfirmRunning: '运行请求中…',
extConfirmError: '运行请求失败',
// 运行卡(extension_run 块)
extRunTitle: '🧩 扩展运行进度',
extRunBadgeCreated: '等待开始',
extRunBadgeRunning: '运行中',
extRunBadgeDone: '已完成',
extRunBadgeFailed: '失败',
extRunLogLabel: '日志尾',
extRunLogPlaceholder: '暂无日志',
extRunOutputsLabel: '输出产物',
extRunOutputsEmpty: '无输出清单',
extRunExitCode: '退出码',
// 侧栏「扩展」入口(session 列表底部)
extRailTitle: '扩展',
extPaneTitle: '扩展管理',
extPaneEmpty: '暂无扩展。在对话中说「帮我创建一个扩展:统计 fastq 质量」即可创建。',
extPaneLoading: '扩展加载中…',
extPaneLoadError: '扩展列表加载失败',
extPaneReload: '重新加载',
extPaneDelete: '删除',
extPaneDeleteConfirm: '确定删除扩展「{name}」?删除后不可恢复。',
extPaneConfirmYes: '删除',
extPaneConfirmNo: '取消',
extPaneStatus: '状态',
extPaneStatusDraft: '草稿',
extPaneStatusPublished: '已发布',
extPaneRuns: '运行历史',
extPaneRunsLoading: '历史加载中…',
extPaneRunsEmpty: '暂无运行记录',
extPaneRunsError: '历史加载失败',
extPaneRunsReload: '重试',
extPaneRunsCount: '共 {n} 次运行',
};
/** 8 个 base 镜像(与 176 部署树 stage_image_map.json 一致;默认 bioflow-gf 工具链最全。
* 扩展上传卡下拉与 AI 转换卡推荐镜像共用此表。 */
export const EXT_BASE_IMAGES = [
{ value: 'bioflow-gf', label: 'bioflow-gf(默认,工具链最全)' },
{ value: 'bioflow-gfpy', label: 'bioflow-gfpy' },
{ value: 'bioflow-rnaseq', label: 'bioflow-rnaseq' },
{ value: 'bioflow-rnaR', label: 'bioflow-rnaR' },
{ value: 'bioflow-wgs', label: 'bioflow-wgs' },
{ value: 'bioflow-interproscan', label: 'bioflow-interproscan' },
{ value: 'bioflow-pan', label: 'bioflow-pan' },
{ value: 'bioflow-rde', label: 'bioflow-rde' },
];
/** 阶段状态 → 展示文字(进度卡与面板共用同一映射) */
export function stageStatusText(status) {
return {
+752
View File
@@ -430,6 +430,366 @@ export function patchBioflowQa(aMsg, payload) {
return blk;
}
// ---------------------------------------------------------------
// 用户自定义扩展分析:消息更新器(EXTENSION_CONTRACT.md §4 事件帧 → patch 消息 blocks)
// 与 patchBioflow* 同模式:全部按「找到则 patch / 缺则 push / 未知事件忽略」的幂等规则实现,
// 供 sendBioflow 流的 applyFrame 消费与历史恢复复用。
// 块类型按生命周期步骤:extension_upload(上传引导卡)→ extension_build(AI 转换卡)→
// extension_trial(试运行卡)→ extension_validate(金标/确认卡)→ extension_publish(发布卡)→
// extension_candidates(多命中候选卡)/ extension_run(运行进度卡)。
// ---------------------------------------------------------------
/** 找到(或创建)唯一的上传引导卡块(extension_upload) */
export function ensureExtensionUploadBlock(aMsg) {
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_upload');
if (!blk) {
blk = {
type: 'extension_upload',
status: 'idle', // idle | uploading | created | error
displayName: '',
command: '',
runtimeImage: 'bioflow-gf',
extId: null,
golden: null,
error: null,
files: [], // 上传记录 {name,size,status:'uploading'|'done'|'error',progress}
};
aMsg.blocks.push(blk);
}
return blk;
}
/** 找到(或创建)唯一的运行进度卡块(extension_run;run_id 未定时复用同一块) */
export function ensureExtensionRunBlock(aMsg) {
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_run');
if (!blk) {
blk = {
type: 'extension_run',
runId: null,
extId: null,
status: 'created', // created | running | done | failed
log: '',
outputs: [],
exitCode: null,
error: null,
};
aMsg.blocks.push(blk);
}
return blk;
}
/** extension_upload → 上传引导卡(§4;创建意图命中/歧义消解兜底) */
export function patchExtensionUpload(aMsg, data) {
const blk = ensureExtensionUploadBlock(aMsg);
if (data && typeof data.session_ext_id === 'string') blk.sessionExtId = data.session_ext_id;
if (data && data.message) blk.message = data.message;
return blk;
}
/** extension_created → 上传完成落定(块幂等:上传卡已有 extId 且非空时不覆盖,
* 避免迟到的 created 帧把后续 build 流程的 extId 洗掉) */
export function patchExtensionCreated(aMsg, data) {
const blk = ensureExtensionUploadBlock(aMsg);
if (data && data.ext_id) {
if (!blk.extId) blk.extId = data.ext_id;
if (data.display_name) blk.displayName = data.display_name;
}
if (data && data.status) blk.status = data.status === 'draft' ? 'created' : blk.status;
return blk;
}
/** extension_build → AI 转换卡(command_template/依赖/推荐镜像/nl_triggers 可编辑修正)。
* status=building 时重入更新占位;build_failed 展示错误且不回退已完成内容。 */
export function patchExtensionBuild(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_build' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_build',
extId: data.ext_id,
status: data.status || 'building',
commandTemplate: '',
originalCommand: '',
requirements: { pip: [], apt: [] },
runtimeImage: '',
nlTriggers: [],
analysisType: '',
parameters: [],
error: null,
};
aMsg.blocks.push(blk);
}
const status = data.status || blk.status;
if (status === 'build_failed') {
// 失败必须可见:仅补错误,不回退已展示内容(幂等)
if (data.error) blk.error = data.error;
blk.status = 'build_failed';
return blk;
}
if (typeof data.command_template === 'string') blk.commandTemplate = data.command_template;
if (typeof data.original_command === 'string') blk.originalCommand = data.original_command;
if (data.requirements && typeof data.requirements === 'object') {
blk.requirements = {
pip: Array.isArray(data.requirements.pip) ? data.requirements.pip : (blk.requirements.pip || []),
apt: Array.isArray(data.requirements.apt) ? data.requirements.apt : (blk.requirements.apt || []),
};
}
if (typeof data.runtime_image === 'string' && data.runtime_image) blk.runtimeImage = data.runtime_image;
if (Array.isArray(data.nl_triggers)) blk.nlTriggers = data.nl_triggers;
if (typeof data.analysis_type === 'string') blk.analysisType = data.analysis_type;
if (Array.isArray(data.parameters)) blk.parameters = data.parameters;
blk.status = status;
return blk;
}
/** extension_trial → 试运行卡(状态/日志尾追加/失败错误;running 时日志只追加不覆盖) */
export function patchExtensionTrial(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_trial' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_trial',
extId: data.ext_id,
status: data.status || 'running',
logTail: '',
exitCode: null,
error: null,
suggestion: null,
};
aMsg.blocks.push(blk);
}
const status = data.status || blk.status;
blk.status = status;
if (typeof data.log_tail === 'string' && data.log_tail) {
// 日志只追加:与 stage_log 同纪律(乱序/重放不重复拼接)
blk.logTail = (blk.logTail || '') + data.log_tail;
}
if (data.exit_code != null) blk.exitCode = data.exit_code;
if (status === 'failed') {
if (data.error) blk.error = data.error;
if (data.suggestion) blk.suggestion = data.suggestion;
} else if (status === 'done') {
blk.error = null;
}
return blk;
}
/** extension_repair → 试运行卡补修复状态(repair_count/suggestion;status=repairing 标循环中) */
export function patchExtensionRepair(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_trial' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_trial',
extId: data.ext_id,
status: 'repairing',
logTail: '',
exitCode: null,
error: null,
suggestion: null,
repairCount: 0,
};
aMsg.blocks.push(blk);
}
if (typeof data.repair_count === 'number') blk.repairCount = data.repair_count;
if (typeof data.suggestion === 'string' && data.suggestion) blk.suggestion = data.suggestion;
if (data.status) blk.status = data.status;
return blk;
}
/** extension_validate → 金标/确认卡(§4;有金标时展示对比结果,参数确认走同卡) */
export function patchExtensionValidate(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_validate' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_validate',
extId: data.ext_id,
golden: null,
formDefaults: [],
validated: false,
};
aMsg.blocks.push(blk);
}
if (data.golden !== undefined) blk.golden = data.golden || null;
if (Array.isArray(data.form_defaults)) blk.formDefaults = data.form_defaults;
if (data.validated != null) blk.validated = !!data.validated;
return blk;
}
/** extension_publish → 发布卡(§4 生命周期发布确认;validate 帧携带 ready_to_publish 时同源挂卡)。
* 块幂等:同 ext_id 重入只刷新 nl_triggers/display_name。 */
export function patchExtensionPublish(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_publish' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_publish',
extId: data.ext_id,
displayName: data.display_name || '',
nlTriggers: Array.isArray(data.nl_triggers) ? data.nl_triggers : [],
published: false,
};
aMsg.blocks.push(blk);
}
if (data.display_name) blk.displayName = data.display_name;
if (Array.isArray(data.nl_triggers)) blk.nlTriggers = data.nl_triggers;
if (data.published != null) blk.published = !!data.published;
return blk;
}
/** extension_published → 发布落定(试运行卡标已发布;发布块幂等去重) */
export function patchExtensionPublished(aMsg, data) {
if (!data || !data.ext_id) return null;
const blk = {
type: 'extension_published',
extId: data.ext_id,
displayName: data.display_name || '',
nlTriggers: Array.isArray(data.nl_triggers) ? data.nl_triggers : [],
};
const existing = (aMsg.blocks || []).find((b) => b.type === 'extension_published' && b.extId === data.ext_id);
if (existing) {
if (data.display_name) existing.displayName = data.display_name;
if (Array.isArray(data.nl_triggers)) existing.nlTriggers = data.nl_triggers;
return existing;
}
aMsg.blocks.push(blk);
// 发布确认卡同源收口(若已挂):published 置位
const pubBlk = (aMsg.blocks || []).find((b) => b.type === 'extension_publish' && b.extId === data.ext_id);
if (pubBlk) pubBlk.published = true;
return blk;
}
/** extension_candidates → 多命中候选卡(§4;purpose 用途文案,点击选择) */
export function patchExtensionCandidates(aMsg, data) {
if (!data || !Array.isArray(data.candidates)) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_candidates');
if (!blk) {
blk = { type: 'extension_candidates', candidates: [], selected: null };
aMsg.blocks.push(blk);
}
blk.candidates = data.candidates
.map((c) => c && typeof c === 'object' ? {
ext_id: c.ext_id || null,
display_name: c.display_name || '',
purpose: c.purpose || '',
} : null)
.filter(Boolean);
return blk;
}
/** extension_confirm → 运行前参数确认卡(§4;块幂等:同 ext_id 重入只刷新参数与提取值) */
export function patchExtensionConfirm(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_confirm' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_confirm',
extId: data.ext_id,
paramsSchema: [],
extractedParams: {},
filesRequired: [],
};
aMsg.blocks.push(blk);
}
if (Array.isArray(data.params_schema)) blk.paramsSchema = data.params_schema;
if (data.extracted_params && typeof data.extracted_params === 'object') blk.extractedParams = data.extracted_params;
if (Array.isArray(data.files_required)) blk.filesRequired = data.files_required;
return blk;
}
/** extension_run_started/run_log/run_done/run_failed → 运行卡(§4;run_id 换新则新建块,
* 同 run_id 幂等:终态不回退、日志只追加、done 结果整体替换) */
export function patchExtensionRunEvent(aMsg, data) {
const type = data && data.type;
if (!type || !data) return null;
const runId = data.run_id;
if (!runId) return null;
const existing = (aMsg.blocks || []).find((b) => b.type === 'extension_run' && b.runId === runId);
const blk = existing || (() => {
const nb = {
type: 'extension_run',
runId,
extId: data.ext_id || null,
status: 'created',
log: '',
outputs: [],
exitCode: null,
error: null,
};
aMsg.blocks.push(nb);
return nb;
})();
if (data.ext_id) blk.extId = data.ext_id;
const TERMINAL = new Set(['done', 'failed']);
if (type === 'extension_run_started') {
// 开始不重置日志(同一 run 的乱序帧防御);状态 running
blk.status = 'running';
} else if (type === 'extension_run_log') {
// 终态不回退(乱序/重放帧防御):done/failed 后迟到的 log 帧不改变状态、不追加日志
if (TERMINAL.has(blk.status)) return blk;
if (typeof data.log === 'string' && data.log) blk.log = (blk.log || '') + data.log;
blk.status = 'running';
} else if (type === 'extension_run_done') {
blk.status = 'done';
blk.outputs = Array.isArray(data.outputs) ? data.outputs : [];
blk.exitCode = 0;
blk.error = null;
} else if (type === 'extension_run_failed') {
blk.status = 'failed';
if (data.exit_code != null) blk.exitCode = data.exit_code;
if (data.error) blk.error = data.error;
if (typeof data.log_tail === 'string') blk.logTail = data.log_tail;
} else {
return null; // 未知事件忽略
}
return blk;
}
/** extension_* 帧统一分派(applyFrame 增的分支;未知事件返回 null 供上层忽略) */
export function applyExtensionFrame(aMsg, type, data) {
switch (type) {
case 'extension_upload':
return patchExtensionUpload(aMsg, data);
case 'extension_created':
return patchExtensionCreated(aMsg, data);
case 'extension_build':
return patchExtensionBuild(aMsg, data);
case 'extension_trial':
return patchExtensionTrial(aMsg, data);
case 'extension_repair':
return patchExtensionRepair(aMsg, data);
case 'extension_validate': {
const blk = patchExtensionValidate(aMsg, data);
// 试运行通过 → 发布引导(validate 帧携带 ready_to_publish/display_name 时挂发布卡;
// 契约 §4 无独立发布引导帧,发布确认由此同源驱动,extension_publish 帧刷新同卡)
if (blk && data && (data.ready_to_publish || data.ready_publish)) {
patchExtensionPublish(aMsg, {
ext_id: data.ext_id,
display_name: data.display_name || '',
nl_triggers: Array.isArray(data.nl_triggers) ? data.nl_triggers : [],
});
}
return blk;
}
case 'extension_publish':
return patchExtensionPublish(aMsg, data);
case 'extension_published':
return patchExtensionPublished(aMsg, data);
case 'extension_candidates':
return patchExtensionCandidates(aMsg, data);
case 'extension_confirm':
return patchExtensionConfirm(aMsg, data);
case 'extension_run_started':
case 'extension_run_log':
case 'extension_run_done':
case 'extension_run_failed':
return patchExtensionRunEvent(aMsg, { ...(data || {}), type });
default:
return null;
}
}
/** 收集本次实际启用的会话能力 chips(联网搜索/答案润色/Superpowers;
* label 取自 settings store 已加载的 manifest)。settings 未就绪时返回空数组(不阻断)。 */
export function collectQaCaps() {
@@ -1361,6 +1721,9 @@ export const useChatStore = defineStore('chat', {
patchBioflowEvent(aMsg, { type, data });
bioflow.applyJobEvent({ type, data });
this.scrollSeq++;
} else if (applyExtensionFrame(aMsg, type, data)) {
// 用户自定义扩展分析(EXTENSION_CONTRACT.md §4):extension_* 帧 → 引导卡块
this.scrollSeq++;
}
// keepalive / 未知事件:忽略
};
@@ -1482,6 +1845,337 @@ export const useChatStore = defineStore('chat', {
&& m.blocks.some((b) => b && typeof b.type === 'string' && b.type.startsWith('bioflow')));
},
// ---------------------------------------------------------------
// 用户自定义扩展分析:对话引导流(EXTENSION_CONTRACT.md §4/§5)
// 入口 = 生信 chat/stream(A 层创建意图/歧义消解由服务端 intent.py 判定,
// 前端 intent.js 词典与后端同表双跑,漏判安全);返回帧为 extension_* 事件,
// 经 applyExtensionFrame 逐帧 patch 引导卡块(上传卡 → 转换卡 → 试运行卡 → …)。
// 块更新器与 patchBioflow* 同幂等纪律,供流消费与历史恢复复用。
// ---------------------------------------------------------------
/** 新建一条扩展分析骨架消息:上传引导卡等待会话扩展(extension_upload 事件回填) */
newExtensionSkeletonMessage() {
return {
id: null,
role: 'assistant',
kind: 'bioflow', // 会话上下文/恢复判定沿用生信消息(扩展是生信域内并行链路)
status: 'streaming',
streamErr: null,
interrupted: false,
blocks: [
{
type: 'extension_upload',
status: 'idle',
displayName: '',
command: '',
runtimeImage: 'bioflow-gf',
extId: null,
golden: null,
error: null,
files: [],
},
{
type: 'pipeline',
derived: true,
status: 'running',
steps: AGENT_DOT_PHASES.map((phase) => ({
name: PHASE_LABEL[phase],
state: 'waiting',
desc: PHASE_DESC[phase],
phase,
})),
},
],
raw: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
},
/** 发送扩展分析消息(与 sendBioflow 同模式:用户消息落库 → 扩展骨架 → chat/stream
* SSE → applyExtensionFrame 分派 → 终态收口)。发流失败时骨架按失败可见原则标注可重试。 */
async sendExtension(text, { userMsg, mode = 'ask' } = {}) {
const q = String(text == null ? '' : text).trim();
if (!q || this.busy) return;
const sessions = useSessionsStore();
if (!this.sessionId) {
try {
const sess = await sessions.create();
this.sessionId = sess.id;
} catch {
return;
}
}
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
}
const sessionId = this.sessionId;
const msgList = this.messages;
this.sessionMsgs[sessionId] = msgList;
const ctx = newStreamCtx(sessionId, msgList);
this.streamCtx[sessionId] = ctx;
this.lastResult = null;
const startAt = Date.now();
// 1. 用户消息入列 + 落库(retry 复用既有用户消息时跳过——同位置替换)
let userId = null;
let userMsgRef = null;
if (userMsg) {
userMsgRef = userMsg;
userId = userMsg.id;
} else {
const userMsgRaw = { id: null, role: 'user', kind: 'bioflow', status: 'done', text: q, mode, meta: { extension: true }, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(userMsgRaw);
userMsgRef = msgList[msgList.length - 1]; // 持代理(响应式断链陷阱)
try {
const saved = await createMessage(sessionId, { role: 'user', text: q });
userId = saved.id;
userMsgRef.id = userId;
} catch (err) {
userMsgRef.persistError = err;
this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false });
return;
}
}
// 2. 会话 running
sessions.patchLocal(sessionId, { status: 'running' });
try {
await updateSessionQuiet(sessionId, { status: 'running' });
} catch { /* 状态 PATCH 失败不阻断 */ }
// 3. 扩展骨架消息(上传引导卡等待 extension_upload 帧)+ 落库
const aMsgRaw = this.newExtensionSkeletonMessage();
msgList.push(aMsgRaw);
const aMsg = msgList[msgList.length - 1]; // 持代理
ctx.aMsg = aMsg;
this.runningMsgId = null;
try {
const skeleton = await createMessage(sessionId, {
role: 'assistant', kind: 'bioflow', status: 'streaming',
blocks: [{ type: 'extension_upload', status: 'idle', extId: null, files: [] }],
});
aMsg.id = skeleton.id;
this.runningMsgId = skeleton.id;
} catch {
aMsg.id = null; // 骨架落库失败:终态用 replace 语义重建(防御)
}
// 4. 事件流(复用生信 chat/stream 规划入口;extension_* 帧经 applyExtensionFrame 分派)
const controller = new AbortController();
ctx.controller = controller;
let ended = false;
const endTask = () => {
ended = true;
if (ctx.controller === controller) ctx.controller = null;
this._stopCheckpoint(ctx);
};
const updateLocal = (fn) => {
if (ctx.msgList.includes(aMsg)) fn();
};
const applyFrame = (ev) => {
if (ended) return;
const type = ev.event || 'message';
const data = ev.data || {};
if (type === 'message_done') {
// 终态收口:与 sendBioflow 同纪律(消息终态、ctx 清理、busy 释放)
endTask();
updateLocal(() => { aMsg.status = 'done'; });
this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: true,
payload: null,
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
dur: ((Date.now() - startAt) / 1000).toFixed(1) + 's',
});
return;
}
if (type === 'error') {
// 统一错误信封形态:标注可重试(失败必须可见)
endTask();
const dotBlk = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline' && b.derived);
if (dotBlk) {
for (const s of dotBlk.steps || []) {
if (s.state === 'running') s.state = 'error';
}
}
const code = (data && data.code) || 'STREAM_ERROR';
const message = (data && data.message) || BF.bioflowRouteError;
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'message';
aMsg.errText = message;
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code, message, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
if (applyExtensionFrame(aMsg, type, data)) {
// 扩展事件收口时(发布/运行终态)推进派生 pipeline 点,避免永久 waiting
if (type === 'extension_published') {
const dotBlk = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline' && b.derived);
if (dotBlk) dotBlk.steps.forEach((s) => { if (s.state === 'waiting') s.state = 'done'; });
}
this.scrollSeq++;
}
// keepalive / 未知事件:忽略
};
try {
const res = await bioflowChatStream(
{
session_id: sessionId,
message: q,
has_attachments: false,
has_active_artifact: this._hasActiveArtifactSafe(),
has_bioflow_context: this._sessionHasBioflowContext(sessionId),
last_seq: null,
},
{ signal: controller.signal },
);
await consumeSseStream(res, applyFrame);
if (!ended) {
endTask();
updateLocal(() => { aMsg.status = 'done'; });
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
try {
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
} catch (persistErr) {
console.warn('persistFinal failed after extension stream:', persistErr);
}
this._finishCtx(ctx, {
ok: true,
payload: null,
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
dur,
});
}
} catch (err) {
if (ended) return; // 已被某个帧处理(message_done/error)
endTask();
const isAbort = err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED');
if (isAbort) {
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'stopped';
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: 'ABORTED', message: T.stopped, retryable: false },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
if (!isAbort && err && typeof err.code === 'string' && err.code !== 'NETWORK') {
const text = err.message || BF.bioflowRouteError;
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'message';
aMsg.errText = text;
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: err.code, message: text, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
// 网络中断:按中断标注(引导卡块已收内容保留,可重试)
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'disconnected';
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: 'NETWORK', message: T.disconnected, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
}
},
/** 上传扩展 zip 并创建(ExtensionUploadCard 提交;FormData multipart → POST /extensions)。
* 结果 patch 进当前会话最近一条 extension_upload 块(上传/创建失败可见可重试)。 */
async uploadExtensionFile(blk, { zip, displayName, command, runtimeImage, golden }, { onProgress } = {}) {
const { createExtension } = await import('../api/bioflow.js');
const form = new FormData();
form.append('file', zip, zip.name || 'extension.zip');
form.append('display_name', String(displayName || '').trim());
form.append('command', String(command || '').trim());
if (runtimeImage) form.append('runtime_image', String(runtimeImage));
if (golden && golden instanceof Blob) form.append('golden', golden, golden.name || 'golden');
const item = { name: zip.name || 'extension.zip', size: zip.size || 0, status: 'uploading', progress: 0 };
if (blk && !(blk.files || []).some((f) => f.name === item.name)) blk.files = [...(blk.files || []), item];
try {
const data = await createExtension(form, {
onUploadProgress: (e) => {
item.progress = e.pct;
if (typeof onProgress === 'function') onProgress(e.pct, item);
},
});
item.status = 'done';
item.progress = 1;
const result = (data && data.data) || data || {};
if (blk) {
if (!blk.extId) blk.extId = result.ext_id || result.id || null;
if (blk.extId) blk.status = 'created';
if (result.display_name) blk.displayName = result.display_name;
}
return result;
} catch (err) {
item.status = 'error';
item.error = err?.message || '';
throw err;
}
},
/** 试运行卡提交测试输入文件(可选金标输出)→ POST /extensions/{id}/trial-run。
* 返回登记结果;上传进度经 onUploadProgress 透传(卡内展示)。 */
async trialRunExtension(extId, files, { onProgress } = {}) {
const { trialRunExtension: apiTrial } = await import('../api/bioflow.js');
const form = new FormData();
for (const f of files) form.append('test_inputs', f, f.name || 'test_input');
const data = await apiTrial(extId, form, { timeout: 0, onUploadProgress: onProgress });
return (data && data.data) || data || {};
},
/** 当前会话最近一条 extension_upload 块(供上传进度/结果 patch) */
_latestExtensionUploadBlock() {
const list = this.messages || [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (m && m.role === 'assistant' && Array.isArray(m.blocks)) {
const blk = m.blocks.find((b) => b && b.type === 'extension_upload');
if (blk) return { aMsg: m, blk };
}
}
return null;
},
// ---------------------------------------------------------------
// P2 生信文件:上传(XHR 进度)与服务器路径登记(FR-BIO-006/007)。
// 供 FlowFileCard 与 Composer 拖拽分支共用;进度/结果 patch 到当前会话
@@ -1608,6 +2302,57 @@ export const useChatStore = defineStore('chat', {
return result;
},
/** 歧义消解兜底(§5):无创建动作词且无已发布扩展命中 → 「没有匹配的已发布扩展,
* 是否创建?」引导卡(extension_upload 块带歧义注记)。幂等:同块只补一次。 */
patchExtensionNoMatch(aMsg, data) {
if (!aMsg) return null;
const blk = ensureExtensionUploadBlock(aMsg);
if (!blk.noMatch) blk.noMatch = (data && data.message) || null;
return blk;
},
/** 运行前参数确认卡提交(§5 运行时参数来源:确认卡手填值优先于提取值)→
* POST /extensions/{id}/run → 挂运行卡(run_started 形态)→ 返回 run_id。
* 参数合并:extractedParams(用户句子提取值)为底,确认卡手填值覆盖。 */
async confirmExtensionRun(blk, { params = {}, files = [], aMsg = null } = {}) {
const { runExtension } = await import('../api/bioflow.js');
const extId = blk.extId;
if (!extId) throw new Error('no ext');
let body;
if (Array.isArray(files) && files.length) {
const form = new FormData();
form.append('params', JSON.stringify(params || {}));
for (const f of files) form.append('files', f, f.name || 'input');
body = form;
} else {
body = { params: params || {} };
}
const data = await runExtension(extId, body, Array.isArray(files) && files.length ? { timeout: 0 } : {});
const result = (data && data.data) || data || {};
const runId = result.run_id || null;
const target = aMsg || (() => {
const list = this.messages || [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (m && m.role === 'assistant' && Array.isArray(m.blocks)) {
const b = m.blocks.find((x) => x && x.type === 'extension_confirm' && x.extId === extId);
if (b) return m;
}
}
return null;
})();
if (target && runId) {
blk.submitted = true;
const runBlk = patchExtensionRunEvent(target, {
type: 'extension_run_started',
run_id: runId,
ext_id: extId,
});
if (runBlk) this.scrollSeq++;
}
return result;
},
/** P4 复用发起(FR-BIO-023):把复用预填表单挂到当前会话消息流
* (bioflow_reuse 块,FlowFormCard mode='reuse' 渲染)。
* prefill:bioflow.loadReusePrefill 产物(archive/stageChain/params)。 */
@@ -2095,6 +2840,13 @@ export const useChatStore = defineStore('chat', {
}
// P2:生信失败消息的重试走生信路径(同位置替换;reuseName 从 user.meta 恢复)
if (aMsg.kind === 'bioflow' || user.kind === 'bioflow') {
// 扩展分析消息(user.meta.extension 标记):重试走扩展引导流(同位置替换)
if (user.meta && user.meta.extension) {
msgList.splice(idx, 1);
this.lastResult = null;
await this.sendExtension(q, { userMsg: user });
return;
}
msgList.splice(idx, 1);
this.lastResult = null;
await this.sendBioflow(q, { userMsg: user, reuseName: (user.meta && user.meta.reuseName) || null });
+100
View File
@@ -0,0 +1,100 @@
// stores/extensions.js —— 用户自定义扩展分析(EXTENSION_CONTRACT.md;dev/extension-frontend 归属)
// 侧栏「扩展」入口的数据源:已发布扩展列表 / 单扩展运行历史 / 删除。
// 创建流程全在对话引导卡内完成(chat store applyExtensionFrame 消费 §4 事件帧)——
// 本 store 只做 CRUD 读面,不参与生命周期状态机(后端 extensions.py 是唯一事实源)。
// 零新增依赖:底层走 api/bioflow.js 的扩展端点封装(http.js 统一错误信封)。
import { defineStore } from 'pinia';
import {
listExtensions,
getExtension,
deleteExtension,
listExtensionRuns,
} from '../api/bioflow.js';
export const EXTENSION_STATUS_DRAFT = new Set(['draft', 'building', 'built', 'trial_running', 'trial_passed',
'trial_failed', 'validating', 'validated', 'repairing', 'confirming', 'build_failed']);
export const EXTENSION_STATUS_PUBLISHED = new Set(['published']);
export const useExtensionsStore = defineStore('extensions', {
state: () => ({
items: [], // 扩展列表(published 优先,服务端已排序)
loading: false, // 列表首次加载中
loaded: false, // 是否完成过一次加载
error: false, // 列表加载失败(失败必须可见)
deleting: {}, // extId -> true(删除中防重复)
runs: {}, // extId -> { data, at, loading, error }(运行历史)
runsLoading: {}, // extId -> true(加载中)
openRuns: {}, // extId -> true(历史展开状态,组件本地也可)
}),
getters: {
published(state) {
return state.items.filter((e) => e && EXTENSION_STATUS_PUBLISHED.has(e.status));
},
count(state) {
return state.items.length;
},
},
actions: {
/** 列表加载(侧栏入口打开时调用);失败置 error(失败必须可见) */
async load({ force = false } = {}) {
if (!force && this.loaded) return;
this.loading = true;
this.error = false;
try {
const data = await listExtensions();
this.items = Array.isArray(data) ? data : [];
this.loaded = true;
} catch (err) {
this.error = true;
this.loaded = true;
throw err;
} finally {
this.loading = false;
}
},
/** 单扩展详情(extension.json;侧栏展开历史/确认状态用) */
async fetch(extId) {
const data = await getExtension(extId);
const item = this.items.find((e) => e.id === extId || e.ext_id === extId);
if (item) Object.assign(item, (data && data.data) || data || {});
return (data && data.data) || data || {};
},
/** 删除扩展(二次确认在组件层;后端不回收镜像策略见契约 §B2) */
async remove(extId) {
if (this.deleting[extId]) return;
this.deleting = { ...this.deleting, [extId]: true };
try {
await deleteExtension(extId);
this.items = this.items.filter((e) => (e.id || e.ext_id) !== extId);
delete this.runs[extId];
} finally {
const next = { ...this.deleting };
delete next[extId];
this.deleting = next;
}
},
/** 单扩展运行历史(GET /extensions/{id}/runs) */
async loadRuns(extId) {
const key = extId;
this.runsLoading = { ...this.runsLoading, [key]: true };
try {
const data = await listExtensionRuns(extId);
const list = Array.isArray(data) ? data : (Array.isArray((data || {}).runs) ? data.runs : []);
this.runs = { ...this.runs, [key]: { data: list, at: Date.now(), error: false } };
return list;
} catch (err) {
this.runs = { ...this.runs, [key]: { data: [], at: Date.now(), error: true } };
throw err;
} finally {
const next = { ...this.runsLoading };
delete next[key];
this.runsLoading = next;
}
},
},
});
+17 -1
View File
@@ -248,7 +248,7 @@ export function extractReuseName(text) {
* @param {boolean} hasAttachments 本轮是否带附件
* @param {boolean} hasActiveArtifact 是否存在活动文档(文档 TAB 当前打开的 artifact)
* @param {boolean} hasBioflowContext 当前会话是否已有生信任务/生信对话上下文
* @returns {{intent: 'qa'|'docgen'|'edit'|'export'|'bioflow'|'bioflow_qa', reuseName: string|null}}
* @returns {{intent: 'qa'|'docgen'|'edit'|'export'|'bioflow'|'bioflow_qa'|'extension', reuseName: string|null}}
*/
export function route(text, hasAttachments = false, hasActiveArtifact = false, hasBioflowContext = false) {
const t = String(text == null ? '' : text).trim();
@@ -311,4 +311,20 @@ export function route(text, hasAttachments = false, hasActiveArtifact = false, h
return { intent: 'qa', reuseName: null };
}
// ---------------------------------------------------------------
// 用户自定义扩展分析:创建意图 A 层词典(EXTENSION_CONTRACT.md §5)
// 形态 = 「动作词 + 目标词 + 描述」,动作词 ∈ {创建,建,新建,上传,自定义,写,弄,做一个},
// 目标词 ∈ {扩展,拓展分析,分析模板,分析脚本,分析工具},同句命中即进创建流程
// (上传引导卡)。歧义消解(无动作词 + 已发布扩展命中)由服务端 intent.py 判定,
// 前端只做确定性创建意图识别——漏判安全:不命中一律回落 bioflow/qa。
// ---------------------------------------------------------------
const EXT_ACTION_WORD = ['创建', '建', '新建', '上传', '自定义', '写', '弄', '做一个'];
const EXT_TARGET_WORD = ['拓展分析', '扩展分析', '扩展', '分析模板', '分析脚本', '分析工具'];
/** A 层创建意图命中(同句含动作词 + 目标词);纯函数,词典与后端 intent.py 同表 */
export function isExtensionCreateIntent(text) {
const tl = String(text == null ? '' : text).slice(0, 2000).toLowerCase();
return hasAnyWord(tl, EXT_ACTION_WORD) && hasAnyWord(tl, EXT_TARGET_WORD);
}
export default route;
+642
View File
@@ -0,0 +1,642 @@
// tests/r3debug/extensions.test.js —— 用户自定义扩展分析前端单测(dev/extension-frontend 归属)
// 覆盖:
// 1) §4 事件帧 → 块映射(applyExtensionFrame 全事件集幂等/追加/终态纪律)
// 2) sendExtension 流(用户消息落库 → 骨架 → SSE 帧分派 → 终态收口/错误标注)
// 3) uploadExtensionFile / trialRunExtension / confirmExtensionRun store 动作
// 4) 卡片组件 mount 冒烟(8 个扩展卡 + SessionRail 扩展入口)
// 5) intent.js 创建意图词典(§5 A 层同表双跑)
//
// 运行方式(worktree 内):cd ui && npx vitest run --config tests/r3debug/vitest.config.js tests/r3debug/extensions.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
// jsdom 无 scrollTo:polyfill(组件挂载用)
if (typeof window !== 'undefined' && typeof window.Element !== 'undefined' && !window.Element.prototype.scrollTo) {
window.Element.prototype.scrollTo = vi.fn();
}
// ---- mock 依赖链(与 bioflowPathConfirm.test.js 同款) ----
vi.mock('../../src/api/sessions.js', () => ({
createMessage: vi.fn(async () => ({ id: 'm' + Math.random().toString(36).slice(2, 6) })),
updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })),
}));
vi.mock('../../src/api/qa.js', () => ({
chatStream: vi.fn(async () => {}),
search: vi.fn(async () => ({ answer: '' })),
}));
vi.mock('../../src/api/private.js', () => ({
askPrivate: vi.fn(async () => ({ hit: false })),
}));
vi.mock('../../src/api/docgen.js', () => ({
generate: vi.fn(), getTask: vi.fn(),
getArtifactContent: vi.fn(async () => ({ markdown: '' })),
getArtifactHtml: vi.fn(async () => ({ ok: true, html: '', css: '' })),
saveArtifactContent: vi.fn(async () => ({})), editArtifact: vi.fn(async () => ({})),
exportArtifact: vi.fn(async () => ({})), uploadLibrary: vi.fn(),
listLibrary: vi.fn(async () => ({ items: [] })), removeLibrary: vi.fn(async () => ({ ok: true })),
listTasks: vi.fn(async () => ({ tasks: [] })),
}));
// ---- 扩展 API 层 mock(观测调用 + 可控返回;vi.hoisted 供 mock factory 引用) ----
const apiMock = vi.hoisted(() => ({
createExtension: vi.fn(),
buildExtension: vi.fn(),
trialRunExtension: vi.fn(),
repairExtension: vi.fn(),
publishExtension: vi.fn(),
runExtension: vi.fn(),
listExtensions: vi.fn(async () => []),
getExtension: vi.fn(async () => ({})),
deleteExtension: vi.fn(async () => ({ ok: true })),
listExtensionRuns: vi.fn(async () => []),
extensionRunEvents: vi.fn(),
bioflowChatStream: vi.fn(),
}));
vi.mock('../../src/api/bioflow.js', () => apiMock);
// ---- bioflow store mock(sendExtension 需要 useBioflowStore;零调用断言) ----
vi.mock('../../src/stores/bioflow.js', () => ({
useBioflowStore: () => ({
resetSession: vi.fn(),
currentJob: { jobId: null, jobStatus: null, stages: [], lastSeq: 0, autoContinue: false },
connectJobEvents: vi.fn(),
applyJobEvent: vi.fn(),
applyChatEvent: vi.fn(),
registerReport: vi.fn(async () => {}),
}),
}));
// ---- 被测模块 ----
import {
useChatStore,
applyExtensionFrame,
patchExtensionUpload,
patchExtensionCreated,
patchExtensionBuild,
patchExtensionTrial,
patchExtensionRepair,
patchExtensionValidate,
patchExtensionPublish,
patchExtensionPublished,
patchExtensionCandidates,
patchExtensionConfirm,
patchExtensionRunEvent,
ensureExtensionUploadBlock,
ensureExtensionRunBlock,
} from '../../src/stores/chat.js';
import { useSessionsStore } from '../../src/stores/sessions.js';
import { useExtensionsStore } from '../../src/stores/extensions.js';
import { isExtensionCreateIntent } from '../../src/utils/intent.js';
import { consumeSseStream } from '../../src/sse/parser.js';
import ExtensionUploadCard from '../../src/components/chat/ExtensionUploadCard.vue';
import ExtensionBuildCard from '../../src/components/chat/ExtensionBuildCard.vue';
import ExtensionTrialCard from '../../src/components/chat/ExtensionTrialCard.vue';
import ExtensionValidateCard from '../../src/components/chat/ExtensionValidateCard.vue';
import ExtensionPublishCard from '../../src/components/chat/ExtensionPublishCard.vue';
import ExtensionCandidatesCard from '../../src/components/chat/ExtensionCandidatesCard.vue';
import ExtensionConfirmCard from '../../src/components/chat/ExtensionConfirmCard.vue';
import ExtensionRunCard from '../../src/components/chat/ExtensionRunCard.vue';
import SessionRail from '../../src/components/session/SessionRail.vue';
/** 构造一个最小 SSE Response(供 bioflowChatStream mock 返回) */
function makeSseResponse(frames) {
const events = frames.map((f) => {
const lines = [`event: ${f.event}`];
if (f.data !== undefined) lines.push(`data: ${JSON.stringify(f.data)}`);
return lines.join('\n');
}).join('\n\n');
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(events + (events ? '\n\n' : '')));
controller.close();
},
});
return { ok: true, status: 200, body: stream };
}
function freshStores() {
const pinia = createPinia();
setActivePinia(pinia);
const chat = useChatStore();
const sessions = useSessionsStore();
chat.sessionId = 's1';
sessions.activeId = 's1';
sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }];
return { chat, sessions };
}
function plainMsg(blocks = []) {
return { id: 'am', role: 'assistant', status: 'streaming', blocks: [...blocks], raw: null };
}
describe('applyExtensionFrame §4 事件帧 → 块映射', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('extension_upload → 上传引导卡(缺则 push;重入幂等不重复建块)', () => {
const msg = plainMsg();
const b1 = applyExtensionFrame(msg, 'extension_upload', { session_ext_id: 'sess-ext-1' });
expect(msg.blocks.length).toBe(1);
expect(msg.blocks[0].type).toBe('extension_upload');
expect(msg.blocks[0].sessionExtId).toBe('sess-ext-1');
const b2 = applyExtensionFrame(msg, 'extension_upload', { session_ext_id: 'sess-ext-1', message: 'm' });
expect(msg.blocks.length).toBe(1);
expect(b2 === b1).toBe(true);
expect(msg.blocks[0].message).toBe('m');
});
it('extension_created → 上传卡落定 extId;迟到帧不覆盖已有 extId', () => {
const msg = plainMsg();
patchExtensionCreated(msg, { ext_id: 'ext-1', display_name: '测序质量统计', status: 'draft' });
expect(msg.blocks[0].extId).toBe('ext-1');
expect(msg.blocks[0].displayName).toBe('测序质量统计');
expect(msg.blocks[0].status).toBe('created');
patchExtensionCreated(msg, { ext_id: 'ext-late', status: 'draft' });
expect(msg.blocks[0].extId).toBe('ext-1'); // 不覆盖
});
it('extension_build → 转换卡;同 ext_id 重入刷新,build_failed 只补错误不回退', () => {
const msg = plainMsg();
applyExtensionFrame(msg, 'extension_build', {
ext_id: 'ext-1',
command_template: 'python {code_dir}/q.py --out {output_dir}',
requirements: { pip: ['pandas'], apt: [] },
runtime_image: 'bioflow-gfpy',
nl_triggers: ['质量统计'],
analysis_type: 'table_filter',
parameters: [{ name: 'fastq', type: 'file', required: true }],
status: 'built',
});
const blk = msg.blocks[0];
expect(blk.type).toBe('extension_build');
expect(blk.commandTemplate).toContain('{output_dir}');
expect(blk.requirements.pip).toEqual(['pandas']);
expect(blk.runtimeImage).toBe('bioflow-gfpy');
expect(blk.nlTriggers).toEqual(['质量统计']);
expect(blk.parameters.length).toBe(1);
// 重入刷新(同 ext_id 不新建块)
applyExtensionFrame(msg, 'extension_build', { ext_id: 'ext-1', nl_triggers: ['统计'] });
expect(msg.blocks.length).toBe(1);
expect(blk.nlTriggers).toEqual(['统计']);
// build_failed 不回退已展示内容
applyExtensionFrame(msg, 'extension_build', { ext_id: 'ext-1', status: 'build_failed', error: 'bad' });
expect(blk.status).toBe('build_failed');
expect(blk.error).toBe('bad');
expect(blk.commandTemplate).toContain('{output_dir}');
});
it('extension_trial → 试运行卡日志只追加;done 清错误;failed 展示错误与修复建议', () => {
const msg = plainMsg();
applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'running', log_tail: 'line1\n' });
applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'running', log_tail: 'line2\n' });
const blk = msg.blocks[0];
expect(blk.logTail).toBe('line1\nline2\n'); // 追加不覆盖
expect(blk.status).toBe('running');
applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'done', exit_code: 0 });
expect(blk.status).toBe('done');
expect(blk.exitCode).toBe(0);
expect(blk.error).toBeNull();
applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'failed', exit_code: 1, error: 'boom', suggestion: 'try X' });
expect(blk.status).toBe('failed');
expect(blk.error).toBe('boom');
expect(blk.suggestion).toBe('try X');
});
it('extension_repair → 试运行卡补修复状态(repair_count/suggestion)', () => {
const msg = plainMsg();
patchExtensionRepair(msg, { ext_id: 'ext-1', repair_count: 2, suggestion: '加 pandas', status: 'repairing' });
expect(msg.blocks[0].type).toBe('extension_trial');
expect(msg.blocks[0].repairCount).toBe(2);
expect(msg.blocks[0].suggestion).toBe('加 pandas');
});
it('extension_validate → 金标/确认卡;ready_to_publish 同源挂发布卡', () => {
const msg = plainMsg();
applyExtensionFrame(msg, 'extension_validate', {
ext_id: 'ext-1',
golden: { ok: true },
form_defaults: [{ name: 'fastq', default: '' }],
ready_to_publish: true,
display_name: '测序质量统计',
nl_triggers: ['质量统计'],
});
const v = msg.blocks.find((b) => b.type === 'extension_validate');
expect(v.golden.ok).toBe(true);
expect(v.formDefaults.length).toBe(1);
const p = msg.blocks.find((b) => b.type === 'extension_publish');
expect(p).toBeTruthy();
expect(p.nlTriggers).toEqual(['质量统计']);
});
it('extension_publish / extension_published → 发布卡与发布确认块(幂等去重)', () => {
const msg = plainMsg();
patchExtensionPublish(msg, { ext_id: 'ext-1', display_name: 'X', nl_triggers: ['a'] });
patchExtensionPublish(msg, { ext_id: 'ext-1', display_name: 'X2', nl_triggers: ['a', 'b'] });
expect(msg.blocks.filter((b) => b.type === 'extension_publish').length).toBe(1);
expect(msg.blocks[0].nlTriggers).toEqual(['a', 'b']);
patchExtensionPublished(msg, { ext_id: 'ext-1', display_name: 'X2', nl_triggers: ['a', 'b'] });
expect(msg.blocks.filter((b) => b.type === 'extension_published').length).toBe(1);
expect(msg.blocks.find((b) => b.type === 'extension_publish').published).toBe(true);
});
it('extension_candidates → 候选卡;重复帧整体替换(幂等)', () => {
const msg = plainMsg();
applyExtensionFrame(msg, 'extension_candidates', { candidates: [{ ext_id: 'e1', display_name: 'A', purpose: 'p1' }] });
applyExtensionFrame(msg, 'extension_candidates', { candidates: [{ ext_id: 'e2', display_name: 'B', purpose: 'p2' }] });
expect(msg.blocks.length).toBe(1);
expect(msg.blocks[0].candidates).toEqual([{ ext_id: 'e2', display_name: 'B', purpose: 'p2' }]);
});
it('extension_confirm → 参数确认卡;同 ext_id 重入只刷新(块幂等)', () => {
const msg = plainMsg();
patchExtensionConfirm(msg, { ext_id: 'ext-1', params_schema: [{ name: 'fastq' }], extracted_params: { fastq: '/a.fq' }, files_required: ['a.fq'] });
patchExtensionConfirm(msg, { ext_id: 'ext-1', extracted_params: { fastq: '/b.fq' } });
expect(msg.blocks.length).toBe(1);
expect(msg.blocks[0].extractedParams.fastq).toBe('/b.fq');
expect(msg.blocks[0].filesRequired).toEqual(['a.fq']);
});
it('extension_run_* → 运行卡:run_id 换新则新块;日志追加;终态不回退', () => {
const msg = plainMsg();
patchExtensionRunEvent(msg, { type: 'extension_run_started', run_id: 'r1', ext_id: 'ext-1' });
patchExtensionRunEvent(msg, { type: 'extension_run_log', run_id: 'r1', log: 'a\n' });
patchExtensionRunEvent(msg, { type: 'extension_run_log', run_id: 'r1', log: 'b\n' });
expect(msg.blocks.length).toBe(1);
expect(msg.blocks[0].log).toBe('a\nb\n');
expect(msg.blocks[0].status).toBe('running');
patchExtensionRunEvent(msg, { type: 'extension_run_done', run_id: 'r1', outputs: ['out/x'] });
expect(msg.blocks[0].status).toBe('done');
expect(msg.blocks[0].outputs).toEqual(['out/x']);
// 终态不回退:迟到的 log 帧不改变 done、不追加日志
patchExtensionRunEvent(msg, { type: 'extension_run_log', run_id: 'r1', log: 'late' });
expect(msg.blocks[0].status).toBe('done');
expect(msg.blocks[0].log).toBe('a\nb\n');
// 失败帧
patchExtensionRunEvent(msg, { type: 'extension_run_failed', run_id: 'r2', ext_id: 'ext-1', exit_code: 3, error: 'empty', log_tail: 'tail' });
const f = msg.blocks.find((b) => b.runId === 'r2');
expect(f.status).toBe('failed');
expect(f.exitCode).toBe(3);
expect(f.error).toBe('empty');
// 新 run 不覆盖旧块
expect(msg.blocks.length).toBe(2);
});
it('未知 extension 事件忽略;无 run_id/无 ext_id 防御返回 null', () => {
const msg = plainMsg();
expect(applyExtensionFrame(msg, 'extension_unknown', {})).toBeNull();
expect(msg.blocks.length).toBe(0);
expect(patchExtensionRunEvent(msg, { type: 'extension_run_log', log: 'x' })).toBeNull();
expect(patchExtensionBuild(msg, { command_template: 'x' })).toBeNull();
});
it('ensure 块函数幂等(upload/run 各一次)', () => {
const msg = plainMsg();
const a = ensureExtensionUploadBlock(msg);
const b = ensureExtensionUploadBlock(msg);
expect(a === b).toBe(true);
expect(msg.blocks.length).toBe(1);
const r1 = ensureExtensionRunBlock(msg);
const r2 = ensureExtensionRunBlock(msg);
expect(r1 === r2).toBe(true);
expect(msg.blocks.length).toBe(2);
});
});
describe('sendExtension 扩展引导流', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('发送 → 用户消息落库 + 扩展骨架(upload 块)+ SSE 帧分派 + 终态收口', async () => {
const { chat } = freshStores();
apiMock.bioflowChatStream.mockResolvedValue(makeSseResponse([
{ event: 'extension_upload', data: { session_ext_id: 'sess-1' } },
{ event: 'extension_created', data: { ext_id: 'ext-1', display_name: 'X', status: 'draft' } },
{ event: 'message_done', data: { kind: 'extension' } },
]));
await chat.sendExtension('创建一个扩展:统计 fastq 质量');
expect(chat.messages.length).toBe(2);
const aMsg = chat.messages[1];
expect(aMsg.kind).toBe('bioflow');
expect(aMsg.status).toBe('done');
const up = aMsg.blocks.find((b) => b.type === 'extension_upload');
expect(up).toBeTruthy();
expect(up.extId).toBe('ext-1');
// 终态收口:ctx 已清理、busy 释放
expect(chat.busy).toBe(false);
expect(chat.lastResult.ok).toBe(true);
});
it('error 帧 → 消息标注可重试 + 收口', async () => {
const { chat } = freshStores();
apiMock.bioflowChatStream.mockResolvedValue(makeSseResponse([
{ event: 'extension_upload', data: { session_ext_id: 'sess-1' } },
{ event: 'error', data: { code: 'UPSTREAM_DOWN', message: '服务不可用' } },
]));
await chat.sendExtension('创建一个扩展');
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.errText).toBe('服务不可用');
expect(chat.lastResult.ok).toBe(false);
expect(chat.lastResult.error.retryable).toBe(true);
expect(chat.busy).toBe(false);
});
it('发流失败(ApiError 信封)→ 标注可重试', async () => {
const { chat } = freshStores();
const err = new Error('upstream');
err.code = 'UPSTREAM_DOWN';
apiMock.bioflowChatStream.mockRejectedValue(err);
await chat.sendExtension('创建一个扩展');
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.errText).toBe('upstream');
expect(chat.lastResult.ok).toBe(false);
});
it('retry 对扩展消息走 sendExtension(meta.extension 标记)', async () => {
const { chat } = freshStores();
apiMock.bioflowChatStream.mockResolvedValue(makeSseResponse([
{ event: 'extension_upload', data: { session_ext_id: 'sess-1' } },
{ event: 'message_done', data: {} },
]));
await chat.sendExtension('创建一个扩展');
// 手工构造失败态以触发 retry 判定
const aMsg = chat.messages[1];
aMsg.status = 'error';
aMsg.streamErr = 'disconnected';
await chat.retry();
expect(apiMock.bioflowChatStream).toHaveBeenCalledTimes(2);
expect(chat.messages.length).toBe(2); // 同位置替换不新增
expect(chat.messages[1].status).toBe('done');
});
});
describe('扩展 store 动作(upload/trial/confirm)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uploadExtensionFile → FormData 上传 + 块 patch(extId/status/files 进度)', async () => {
const { chat } = freshStores();
apiMock.createExtension.mockResolvedValue({ ok: true, data: { ext_id: 'ext-9', display_name: 'X' } });
const aMsg = { id: 'am', role: 'assistant', status: 'streaming', blocks: [{ type: 'extension_upload', status: 'idle', extId: null, files: [] }], raw: null };
chat.messages = [aMsg];
const blk = aMsg.blocks[0];
const zip = new File(['zip'], 'x.zip', { type: 'application/zip' });
const res = await chat.uploadExtensionFile(blk, { zip, displayName: 'X', command: 'python x.py', runtimeImage: 'bioflow-gf', golden: null });
expect(res.ext_id).toBe('ext-9');
expect(blk.extId).toBe('ext-9');
expect(blk.status).toBe('created');
expect(blk.files[0].status).toBe('done');
// FormData 字段断言
const form = apiMock.createExtension.mock.calls[0][0];
expect(form instanceof FormData).toBe(true);
expect(form.get('display_name')).toBe('X');
expect(form.get('command')).toBe('python x.py');
expect(form.get('runtime_image')).toBe('bioflow-gf');
});
it('uploadExtensionFile 失败 → 文件记录 error 并抛出', async () => {
const { chat } = freshStores();
apiMock.createExtension.mockRejectedValue(new Error('boom'));
const aMsg = { id: 'am', role: 'assistant', status: 'streaming', blocks: [{ type: 'extension_upload', status: 'idle', extId: null, files: [] }], raw: null };
chat.messages = [aMsg];
const blk = aMsg.blocks[0];
await expect(chat.uploadExtensionFile(blk, {
zip: new File(['z'], 'x.zip'),
displayName: 'X',
command: 'cmd',
runtimeImage: 'bioflow-gf',
golden: null,
})).rejects.toThrow('boom');
expect(blk.files[0].status).toBe('error');
expect(blk.extId).toBeNull();
});
it('trialRunExtension → FormData 携带 test_inputs;返回登记结果', async () => {
const { chat } = freshStores();
apiMock.trialRunExtension.mockResolvedValue({ ok: true, data: { accepted: true } });
const f = new File(['t'], 'sample.fq');
const res = await chat.trialRunExtension('ext-1', [f]);
expect(res.accepted).toBe(true);
const form = apiMock.trialRunExtension.mock.calls[0][1];
expect(form instanceof FormData).toBe(true);
expect(form.has('test_inputs')).toBe(true);
});
it('confirmExtensionRun → runExtension + 挂运行卡(run_started 形态)', async () => {
const { chat } = freshStores();
apiMock.runExtension.mockResolvedValue({ ok: true, data: { run_id: 'run-7' } });
const aMsg = {
id: 'am', role: 'assistant', status: 'streaming',
blocks: [
{ type: 'extension_confirm', extId: 'ext-1', paramsSchema: [], extractedParams: {}, filesRequired: [] },
],
raw: null,
};
chat.messages = [aMsg];
const confirmBlk = aMsg.blocks[0];
const res = await chat.confirmExtensionRun(confirmBlk, { params: { fastq: '/a.fq' }, files: [], aMsg });
expect(res.run_id).toBe('run-7');
expect(confirmBlk.submitted).toBe(true);
const runBlk = aMsg.blocks.find((b) => b.type === 'extension_run');
expect(runBlk).toBeTruthy();
expect(runBlk.runId).toBe('run-7');
expect(runBlk.status).toBe('running');
// JSON body 参数透传(无文件时走 JSON 分支)
const body = apiMock.runExtension.mock.calls[0][1];
expect(body.params.fastq).toBe('/a.fq');
});
});
describe('extensions store(侧栏入口数据面)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('load → items 归一化;remove → 删除后出列', async () => {
apiMock.listExtensions.mockResolvedValue([
{ id: 'e1', display_name: 'A', status: 'published' },
{ id: 'e2', display_name: 'B', status: 'draft' },
]);
const pinia = createPinia();
setActivePinia(pinia);
const ext = useExtensionsStore();
await ext.load();
expect(ext.items.length).toBe(2);
expect(ext.published.length).toBe(1);
apiMock.deleteExtension.mockResolvedValue({ ok: true });
await ext.remove('e1');
expect(ext.items.length).toBe(1);
expect(apiMock.deleteExtension).toHaveBeenCalledWith('e1');
});
it('loadRuns → runs 记录(列表/error 两态)', async () => {
apiMock.listExtensionRuns.mockResolvedValue({ runs: [{ run_id: 'r1', status: 'done' }] });
const pinia = createPinia();
setActivePinia(pinia);
const ext = useExtensionsStore();
const list = await ext.loadRuns('e1');
expect(list.length).toBe(1);
expect(ext.runs.e1.error).toBe(false);
apiMock.listExtensionRuns.mockRejectedValue(new Error('x'));
await expect(ext.loadRuns('e2')).rejects.toThrow('x');
expect(ext.runs.e2.error).toBe(true);
});
});
describe('intent.js 创建意图词典(§5 A 层)', () => {
it('动作词 + 目标词同句命中', () => {
expect(isExtensionCreateIntent('帮我创建一个扩展:统计 fastq 质量')).toBe(true);
expect(isExtensionCreateIntent('做一个拓展分析 统计 fastq 质量')).toBe(true);
expect(isExtensionCreateIntent('新建一个分析模板')).toBe(true);
expect(isExtensionCreateIntent('上传我的分析脚本')).toBe(true);
});
it('漏判安全:无动作词 / 无目标词不命中(歧义消解由服务端判定)', () => {
expect(isExtensionCreateIntent('帮我跑水稻 SPL 家族鉴定')).toBe(false);
expect(isExtensionCreateIntent('创建一个文档')).toBe(false);
expect(isExtensionCreateIntent('做一下质量统计')).toBe(false);
expect(isExtensionCreateIntent('扩展分析')).toBe(false);
// 契约 §5:目标词命中但无动作词(「做拓展分析 xxx」)不属 A 层——
// 已发布扩展命中走运行、无命中回「是否创建?」引导卡,均由服务端 intent.py 判定
expect(isExtensionCreateIntent('做拓展分析 统计 fastq 质量')).toBe(false);
});
});
describe('卡片组件 mount 冒烟', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());
});
afterEach(() => {
setActivePinia(createPinia());
});
const mountCard = (Comp, block) => mount(Comp, {
props: { block, message: { id: 'am', blocks: [block] } },
});
it('ExtensionUploadCard 渲染:镜像下拉 8 项 + 提交按钮禁用态(缺 zip)', async () => {
const w = mountCard(ExtensionUploadCard, { type: 'extension_upload', status: 'idle', extId: null, files: [], runtimeImage: 'bioflow-gf' });
expect(w.text()).toContain('扩展上传引导');
const options = w.findAll('option');
expect(options.length).toBe(8);
expect(options[0].text()).toContain('bioflow-gf');
const btn = w.find('button.ext-primary');
expect(btn.attributes('disabled')).toBeDefined(); // 无 zip 提交禁用
});
it('ExtensionBuildCard 渲染:模板/依赖/触发词/参数 + 提交修正', async () => {
const w = mountCard(ExtensionBuildCard, {
type: 'extension_build',
extId: 'ext-1',
status: 'built',
commandTemplate: 'python {code_dir}/q.py --out {output_dir}',
requirements: { pip: ['pandas'], apt: [] },
runtimeImage: 'bioflow-gfpy',
nlTriggers: ['质量统计'],
analysisType: 'table_filter',
parameters: [{ name: 'fastq', type: 'file' }],
});
expect(w.text()).toContain('命令模板');
expect(w.text()).toContain('pandas');
expect(w.text()).toContain('fastq');
const inputs = w.findAll('input.ext-input');
expect(inputs[0].element.value).toContain('{output_dir}');
// 触发词是 input 值(编辑态),不在文本节点中——断言输入值(顺序:模板/镜像/触发词/类型)
expect(inputs[2].element.value).toBe('质量统计');
expect(inputs[1].element.value).toBe('bioflow-gfpy');
});
it('ExtensionTrialCard 渲染:日志尾 + 失败建议 + AI 修复按钮', async () => {
const w = mountCard(ExtensionTrialCard, {
type: 'extension_trial',
extId: 'ext-1',
status: 'failed',
logTail: 'traceback...',
exitCode: 1,
error: 'ModuleNotFoundError',
suggestion: '加 pandas',
});
expect(w.text()).toContain('试运行失败');
expect(w.text()).toContain('traceback...');
expect(w.text()).toContain('加 pandas');
expect(w.text()).toContain('AI 修复');
});
it('ExtensionValidateCard 渲染:金标对比 + 参数确认', async () => {
const w = mountCard(ExtensionValidateCard, {
type: 'extension_validate',
extId: 'ext-1',
golden: { ok: true },
formDefaults: [{ name: 'fastq', default: '', description: '输入' }],
});
expect(w.text()).toContain('金标');
expect(w.text()).toContain('fastq');
});
it('ExtensionPublishCard 渲染:触发词编辑 + 发布确认', async () => {
const w = mountCard(ExtensionPublishCard, {
type: 'extension_publish',
extId: 'ext-1',
displayName: 'X',
nlTriggers: ['a', 'b'],
published: false,
});
expect(w.text()).toContain('发布扩展');
const input = w.find('input.ext-input');
expect(input.element.value).toBe('a, b');
});
it('ExtensionCandidatesCard 渲染:候选列表 + 点击选择 emit', async () => {
const w = mountCard(ExtensionCandidatesCard, {
type: 'extension_candidates',
candidates: [{ ext_id: 'e1', display_name: 'A', purpose: 'p1' }, { ext_id: 'e2', display_name: 'B', purpose: 'p2' }],
selected: null,
});
expect(w.text()).toContain('匹配到多个扩展');
expect(w.text()).toContain('A');
await w.findAll('.ext-row')[1].trigger('click');
expect(w.emitted('pick')).toBeTruthy();
expect(w.emitted('pick')[0][0].ext_id).toBe('e2');
});
it('ExtensionConfirmCard 渲染:参数提取值展示 + 确认运行按钮', async () => {
const w = mountCard(ExtensionConfirmCard, {
type: 'extension_confirm',
extId: 'ext-1',
paramsSchema: [{ name: 'fastq', type: 'file', default: '' }],
extractedParams: { fastq: '/a.fq' },
filesRequired: ['a.fq'],
});
expect(w.text()).toContain('运行参数确认');
const input = w.find('input.ext-input');
expect(input.element.value).toBe('/a.fq');
});
it('ExtensionRunCard 渲染:badge 四态 + 日志 + 输出', async () => {
const w = mountCard(ExtensionRunCard, {
type: 'extension_run',
runId: 'r1',
extId: 'ext-1',
status: 'done',
log: 'a\nb\n',
outputs: ['out/x'],
});
expect(w.text()).toContain('已完成');
expect(w.text()).toContain('out/x');
});
it('SessionRail 渲染:扩展入口 emits goto-extensions', async () => {
const w = mount(SessionRail, { props: { active: true } });
const items = w.findAll('.rail-item');
expect(items.length).toBe(4);
expect(items[1].attributes('title')).toContain('扩展');
await items[1].trigger('click');
expect(w.emitted('goto-extensions')).toBeTruthy();
});
});