Merge branch 'dev/extension-frontend' into feature/bioflow-conversation-orchestration
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 相对该祖先定位(而非视口),
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user