diff --git a/ui/src/api/bioflow.js b/ui/src/api/bioflow.js
index b7ca47a..942b3e6 100644
--- a/ui/src/api/bioflow.js
+++ b/ui/src/api/bioflow.js
@@ -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,
+ });
+}
diff --git a/ui/src/components/chat/AssistantMessage.vue b/ui/src/components/chat/AssistantMessage.vue
index e8960b5..73d308d 100644
--- a/ui/src/components/chat/AssistantMessage.vue
+++ b/ui/src/components/chat/AssistantMessage.vue
@@ -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);
{{ BF.flowQaEmpty }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ BF.extPublishedTitle }}
+
+ {{ BF.extPublishedHint.replace('{name}', b.displayName || '') }}
+
+ {{ BF.extPublishTriggersLabel }}:{{ b.nlTriggers.join('、') }}
+
+
+
+
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;
diff --git a/ui/src/components/chat/Composer.vue b/ui/src/components/chat/Composer.vue
index cccf06c..47422d2 100644
--- a/ui/src/components/chat/Composer.vue
+++ b/ui/src/components/chat/Composer.vue
@@ -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(() => { /* 内部已标注错误 */ });
diff --git a/ui/src/components/chat/ExtensionBuildCard.vue b/ui/src/components/chat/ExtensionBuildCard.vue
new file mode 100644
index 0000000..33ed0e1
--- /dev/null
+++ b/ui/src/components/chat/ExtensionBuildCard.vue
@@ -0,0 +1,340 @@
+
+
+
+
+
+ {{ BF.extBuildTitle }}
+
+ {{ failed ? BF.extBuildStatusFailed : BF.extBuildStatus }}
+
+
+
+
{{ block.error }}
+
+
+
+
+
+
+
+
{{ BF.extBuildTemplateHint }}
+
+
+
+
+
+
+
+
+
+ {{ BF.extBuildPipLabel }}
+ {{ reqs.pip.join(', ') }}
+
+
+ {{ BF.extBuildAptLabel }}
+ {{ reqs.apt.join(', ') }}
+
+
+
{{ BF.extBuildDepsEmpty }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ BF.extBuildTriggersHint }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ p.name }}{{ p.type || 'string' }}
+
+
+
{{ BF.extBuildParamsEmpty }}
+
+
+
+
+
+
+
+ {{ submitError }}
+
+
+
+
+
diff --git a/ui/src/components/chat/ExtensionCandidatesCard.vue b/ui/src/components/chat/ExtensionCandidatesCard.vue
new file mode 100644
index 0000000..fed24de
--- /dev/null
+++ b/ui/src/components/chat/ExtensionCandidatesCard.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
{{ BF.extCandidatesTitle }}
+
+
{{ BF.extCandidatesEmpty }}
+
+
{{ c.display_name }}
+
{{ BF.extCandidatesPurposeLabel }}:{{ c.purpose }}
+
+
+
+
+
diff --git a/ui/src/components/chat/ExtensionConfirmCard.vue b/ui/src/components/chat/ExtensionConfirmCard.vue
new file mode 100644
index 0000000..ebd5cdd
--- /dev/null
+++ b/ui/src/components/chat/ExtensionConfirmCard.vue
@@ -0,0 +1,208 @@
+
+
+
+
+
{{ BF.extConfirmTitle }}
+
{{ BF.extConfirmHint }}
+
+
+ {{ BF.extConfirmFilesLabel }}
+ {{ filesRequired.join('、') }}
+
+
+
+
+
+
setValue(f, e.target.value)"
+ />
+
{{ f.description }}
+
+
+
{{ BF.extConfirmRun }}
+
+
+
+
+
{{ submitError }}
+
+
+
+
diff --git a/ui/src/components/chat/ExtensionPublishCard.vue b/ui/src/components/chat/ExtensionPublishCard.vue
new file mode 100644
index 0000000..1d4ff72
--- /dev/null
+++ b/ui/src/components/chat/ExtensionPublishCard.vue
@@ -0,0 +1,181 @@
+
+
+
+
+
{{ BF.extPublishTitle }}
+
{{ BF.extPublishHint }}
+
+
+
+
+
+
+
+
{{ BF.extPublishedTitle }}
+
{{ BF.extPublishedHint.replace('{name}', block.displayName || '') }}
+
+
+
+
+
+
{{ submitError }}
+
+
+
+
diff --git a/ui/src/components/chat/ExtensionRunCard.vue b/ui/src/components/chat/ExtensionRunCard.vue
new file mode 100644
index 0000000..0a4b649
--- /dev/null
+++ b/ui/src/components/chat/ExtensionRunCard.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+ {{ BF.extRunTitle }}
+
+
+ {{ badgeText }}
+
+
+
+
+
+
+ {{ BF.extRunLogLabel }}
+ {{ BF.extRunExitCode }} {{ block.exitCode }}
+
+
{{ block.log || block.logTail || BF.extRunLogPlaceholder }}
+
+
+
+
{{ block.error }}
+
+
+
+
{{ BF.extRunOutputsLabel }}
+
+
+ {{ typeof o === 'string' ? o : (o.name || o.path || JSON.stringify(o)) }}
+
+
+
{{ BF.extRunOutputsEmpty }}
+
+
+
+
+
diff --git a/ui/src/components/chat/ExtensionTrialCard.vue b/ui/src/components/chat/ExtensionTrialCard.vue
new file mode 100644
index 0000000..21eab72
--- /dev/null
+++ b/ui/src/components/chat/ExtensionTrialCard.vue
@@ -0,0 +1,260 @@
+
+
+
+
+
+ {{ BF.extTrialTitle }}
+
+ {{ failed ? BF.extTrialFailed : done ? BF.extTrialDone : (isRepairing ? BF.extTrialRepairing : BF.extTrialRunning) }}
+
+
+
+
+
+
+
+ {{ testInput.name }}
+
+
+
{{ BF.extTrialTestHint }}
+
+
+
+
+ {{ BF.extTrialLogLabel }}
+ {{ BF.extTrialExitCode }} {{ block.exitCode }}
+
+
{{ block.logTail || BF.extTrialLogPlaceholder }}
+
+
+
+
+ {{ block.error }}
+
+ {{ BF.extTrialRepairSuggestionLabel }}
+ {{ block.suggestion }}
+
+
+
+
+ {{ BF.extTrialRepairCount.replace('{n}', block.repairCount) }}
+
+
+
+
+
{{ submitError }}
+
+
+
+
diff --git a/ui/src/components/chat/ExtensionUploadCard.vue b/ui/src/components/chat/ExtensionUploadCard.vue
new file mode 100644
index 0000000..06776d3
--- /dev/null
+++ b/ui/src/components/chat/ExtensionUploadCard.vue
@@ -0,0 +1,388 @@
+
+
+
+
+
{{ BF.extUploadTitle }}
+
{{ BF.extUploadNoMatchPrefix }}
+
{{ BF.extUploadPrefix }}
+
+
+
+
{{ uploading ? BF.extUploadBusy : BF.extUploadDropHint }}
+
+
+
+
+
+
+ {{ f.name }}
+
+
+ {{ Math.round((f.progress || 0) * 100) }}%
+
+ {{ BF.extUploadDone }}
+ {{ BF.extUploadError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ BF.extUploadImageHint }}
+
+
+
+
+
+
+ {{ golden.name }}
+
+
+
+
{{ BF.extUploadCreated }}
+
+
+
+
{{ submitError }}
+
+
+
+
diff --git a/ui/src/components/chat/ExtensionValidateCard.vue b/ui/src/components/chat/ExtensionValidateCard.vue
new file mode 100644
index 0000000..67e539b
--- /dev/null
+++ b/ui/src/components/chat/ExtensionValidateCard.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
{{ BF.extValidateTitle }}
+
+
+
+ {{ BF.extValidateGoldenLabel }}
+ ✓
+ ✕
+ {{ BF.extValidateGoldenNone }}
+
+
+
+
+
+
+
+
{{ f.description }}
+
+
+
+
+
+
+
{{ submitError }}
+
+
+
+
diff --git a/ui/src/components/session/ExtensionsPane.vue b/ui/src/components/session/ExtensionsPane.vue
new file mode 100644
index 0000000..4b5e51c
--- /dev/null
+++ b/ui/src/components/session/ExtensionsPane.vue
@@ -0,0 +1,338 @@
+
+
+
+
+
+
+
+ {{ BF.extPaneTitle }}
+
+
+
+
+
{{ BF.extPaneLoading }}
+
+ {{ BF.extPaneLoadError }}
+
+
+
{{ BF.extPaneEmpty }}
+
+
+
+ {{ e.display_name || e.name || e.id }}
+ {{ statusText(e.status) }}
+
+
+
+
+ {{ BF.extPublishTriggersLabel }}:{{ e.nl_triggers.join('、') }}
+
+
+
{{ BF.extPaneRunsLoading }}
+
+
{{ BF.extPaneRunsCount.replace('{n}', runsOf(e).length) }}
+
+ {{ r.run_id }}
+ {{ r.status || '-' }}
+
+
+
{{ BF.extPaneRunsEmpty }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ BF.extPaneDeleteConfirm.replace('{name}', confirmDelete.name) }}
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/components/session/SessionPanel.vue b/ui/src/components/session/SessionPanel.vue
index 44e011f..d326dfe 100644
--- a/ui/src/components/session/SessionPanel.vue
+++ b/ui/src/components/session/SessionPanel.vue
@@ -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 () => {
+
+
+