From ac2fecd00504c16e8ea25247a226bd14f226a468 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Mon, 27 Jul 2026 10:47:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B1=87=E6=8A=A5=E6=B5=81:=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=8C=89=E6=AD=A5=E5=BA=8F=E4=BD=8D=E7=BD=AE=E6=8E=A8=E8=A7=86?= =?UTF-8?q?=E5=9B=BE,=E5=AE=B9=E5=99=A8=E6=B2=99=E7=AE=B1=E6=B8=B2?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「flow:按**步序位置**推送声明的模版」(AM 07)。原先这条链只有写面没有读面, 活动的 viewTemplates 是一行占位字符串,工作台视图区永远只显示一份硬编码概览。 补齐整条链: · 步声明新增 `view`:这一步呈现哪张模版 —— 推什么由声明给定,不由临场决定。 · 新增模版填数的纯函数(interaction/domain):`data-slot` 取值、`data-rows` 逐条展开。 **填数只做取值与转义**,没有表达式、没有分支、没有循环 —— 一旦模版能算, 它就成了代码,容器的「无脚本执行权」也就形同虚设。填充**消费槽位**, 交出去的是干净 HTML,不是带模板属性的半成品。 · interaction 补读面 `viewOf`;desk 路由把推送的 HTML 交给前端; ViewRegion 经 `@ui` 的 ViewContainer(sandbox="" 的 iframe)渲染 —— feature 不碰 innerHTML。 · A1 写了三张真模版(事态全景 / 打标判据 / 成果预览),内联自带样式 (沙箱页面不继承工作台 CSS),按步序位置挂在 S3 / S5 / S6 上。 沿路两处修: ① 取值路径遇到**本身带点号的键**(台账地址 `域.对象`)会被拆成两级、永远取不到。 改为整键优先、找不到再下行;`view-render` 与 `step-sequence` 两处同修。 ② 「本单交付」不能画成台账全表:任务视图是正式版 + 我的草稿的合并结果, 直接拿来列会把**别单**放行过的记录一并列出。本单自己写下的行单独放在 `mine:域.对象` 下(整键,取值不歧义)。 另:视图推送失败原先静默吞掉 —— 会让「视图区一直空着」变成查不出原因的怪事; 改为留痕(仍不影响步序推进,呈现是投影)。 实测:一封 RFQ 入站 → 放行前那一屏列出本单交付 LM358DR / TI / 12000 / 8.2 / 10周, 以及 Type A「直接快答」的分类结论与四项打标判据。 gates: tsc / eslint / depcruise(321 模块)/ api-report / 423 测试 全绿 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../attachments/inbound/rfq-view-01@x.com.eml | 12 ++ .../attachments/inbound/rfq-view-02@x.com.eml | 12 ++ .../attachments/inbound/rfq-view-03@x.com.eml | 12 ++ .../attachments/inbound/rfq-view-04@x.com.eml | 12 ++ .../attachments/inbound/rfq-view-05@x.com.eml | 12 ++ activities/quote-intake/src/manifest.ts | 14 +- activities/quote-intake/src/views/report.ts | 133 ++++++++++++++++++ .../api/src/modules/flow-engine/port.d.ts | 13 ++ .../api/src/modules/interaction/contract.d.ts | 5 + .../api/src/modules/interaction/index.d.ts | 1 + .../api/src/modules/interaction/port.d.ts | 4 + .../packages/contracts/src/api/workbench.d.ts | 2 + .../packages/contracts/src/manifest.d.ts | 20 +++ apps/api/src/app/routes/desk.ts | 9 +- apps/api/src/app/wiring/engines.ts | 26 ++++ .../internal/domain/step-sequence.ts | 8 ++ .../internal/usecase/flow-runner.ts | 28 ++++ apps/api/src/modules/flow-engine/port.ts | 9 ++ .../interaction/__tests__/view-render.test.ts | 110 +++++++++++++++ apps/api/src/modules/interaction/contract.ts | 2 + apps/api/src/modules/interaction/index.ts | 1 + .../internal/adapter/interaction-store.ts | 8 ++ .../internal/domain/view-render.ts | 91 ++++++++++++ .../internal/usecase/interaction-service.ts | 4 + apps/api/src/modules/interaction/port.ts | 1 + apps/web/src/app/styles/theme.css | 15 ++ .../desk-task/internal/ViewRegion.tsx | 13 +- packages/contracts/src/api/workbench.ts | 2 + packages/contracts/src/manifest.ts | 5 + 29 files changed, 574 insertions(+), 10 deletions(-) create mode 100644 .data/attachments/inbound/rfq-view-01@x.com.eml create mode 100644 .data/attachments/inbound/rfq-view-02@x.com.eml create mode 100644 .data/attachments/inbound/rfq-view-03@x.com.eml create mode 100644 .data/attachments/inbound/rfq-view-04@x.com.eml create mode 100644 .data/attachments/inbound/rfq-view-05@x.com.eml create mode 100644 activities/quote-intake/src/views/report.ts create mode 100644 apps/api/src/modules/interaction/__tests__/view-render.test.ts create mode 100644 apps/api/src/modules/interaction/internal/domain/view-render.ts diff --git a/.data/attachments/inbound/rfq-view-01@x.com.eml b/.data/attachments/inbound/rfq-view-01@x.com.eml new file mode 100644 index 0000000..da6e3f1 --- /dev/null +++ b/.data/attachments/inbound/rfq-view-01@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-view-01@x.com", + "from": "buyer@acme.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 D001 RFQ-20260728V1 求报价", + "body": "您好,\n客户编码 D001,询价单号 RFQ-20260728V1。\n需求如下:\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周\n请本周内回复,谢谢。", + "receivedAt": "2026-07-28T07:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-view-02@x.com.eml b/.data/attachments/inbound/rfq-view-02@x.com.eml new file mode 100644 index 0000000..e439a6a --- /dev/null +++ b/.data/attachments/inbound/rfq-view-02@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-view-02@x.com", + "from": "buyer@acme.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 D002 RFQ-20260728V2 求报价", + "body": "客户编码 D002,询价单号 RFQ-20260728V2。\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周", + "receivedAt": "2026-07-28T08:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-view-03@x.com.eml b/.data/attachments/inbound/rfq-view-03@x.com.eml new file mode 100644 index 0000000..5fa0330 --- /dev/null +++ b/.data/attachments/inbound/rfq-view-03@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-view-03@x.com", + "from": "buyer@acme.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C310 RFQ-20260728V3 求报价", + "body": "客户编码 C310,询价单号 RFQ-20260728V3。\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周", + "receivedAt": "2026-07-28T09:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-view-04@x.com.eml b/.data/attachments/inbound/rfq-view-04@x.com.eml new file mode 100644 index 0000000..11c1aaf --- /dev/null +++ b/.data/attachments/inbound/rfq-view-04@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-view-04@x.com", + "from": "buyer@acme.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C320 RFQ-20260728V4 求报价", + "body": "客户编码 C320,询价单号 RFQ-20260728V4。\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周", + "receivedAt": "2026-07-28T10:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-view-05@x.com.eml b/.data/attachments/inbound/rfq-view-05@x.com.eml new file mode 100644 index 0000000..47e2a6d --- /dev/null +++ b/.data/attachments/inbound/rfq-view-05@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-view-05@x.com", + "from": "buyer@acme.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C330 RFQ-20260728V5 求报价", + "body": "客户编码 C330,询价单号 RFQ-20260728V5。\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周", + "receivedAt": "2026-07-28T11:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/activities/quote-intake/src/manifest.ts b/activities/quote-intake/src/manifest.ts index ef132dc..ba333dd 100644 --- a/activities/quote-intake/src/manifest.ts +++ b/activities/quote-intake/src/manifest.ts @@ -8,6 +8,7 @@ */ import type { ActivityManifest, DmnModel } from '@contracts'; import { activityIdSchema } from '@contracts'; +import { INTAKE_CLASSIFIED, INTAKE_OVERVIEW, INTAKE_RELEASE } from './views/report.ts'; const INTAKE_DOMAIN = 'quote_intake'; const ACTIVITY_ID = activityIdSchema.parse('quote-intake'); @@ -40,7 +41,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A1 · RFQ 需求分析', - packageVersion: '0.1.5', + packageVersion: '0.1.7', category: '统一报价', paradigm: 'flow', sdkVersion: '0.1.0-m5', @@ -223,6 +224,7 @@ export const manifest: ActivityManifest = { missingKeyFields: { type: 'integer' }, }, }, + view: 'intake-overview', failure: { retry: { times: 2, backoff: 'PT30S' }, fallback: { kind: 'human', workOrder: 'execution' } }, }, { @@ -270,6 +272,7 @@ export const manifest: ActivityManifest = { fields: { type_tag: 'type', basis: 'play' }, }, tool: 'quote-intake.classify', + view: 'intake-classified', failure: { retry: { times: 1, backoff: 'PT10S' }, fallback: { kind: 'blocked' } }, }, { @@ -279,6 +282,7 @@ export const manifest: ActivityManifest = { input: {}, workOrder: 'acceptance', card: { title: '统一受理记录(打标)请验收' }, + view: 'intake-release', deadline: 'P1D', failure: { retry: { times: 0, backoff: 'PT30S' }, fallback: { kind: 'blocked' } }, }, @@ -286,11 +290,11 @@ export const manifest: ActivityManifest = { parallelGroups: [], }, skills: [], + // 三张模版对应三种局面(事态全景 / 进展 / 成果预览),由步序声明按位置推送 viewTemplates: [ - { - name: 'intake-summary', - html: '

统一受理记录

已归一 条

', - }, + { name: 'intake-overview', html: INTAKE_OVERVIEW }, + { name: 'intake-classified', html: INTAKE_CLASSIFIED }, + { name: 'intake-release', html: INTAKE_RELEASE }, ], cardSchemas: {}, distillHooks: [ diff --git a/activities/quote-intake/src/views/report.ts b/activities/quote-intake/src/views/report.ts new file mode 100644 index 0000000..60b7a5c --- /dev/null +++ b/activities/quote-intake/src/views/report.ts @@ -0,0 +1,133 @@ +/** + * A1 视图模版 —— 视图区呈现的 HTML(AM 07)。 + * + * 「模版是**内容**不是代码」:这里没有脚本、没有事件处理器、没有外链; + * 槽位 `data-slot` 由基座按任务视图填数,行模版 `data-rows` 逐条展开。 + * 容器是沙箱 iframe(无脚本执行权),所以样式必须内联自带 —— 页面不继承工作台的 CSS。 + * + * 三张模版对应三种局面(事态全景 / 进展 / 成果预览),按步序位置推送。 + */ + +/** 共用外壳:一套克制的排版,深浅色都能看 */ +const SHELL = (title: string, kicker: string, accent: string, body: string): string => ` + +
+
${kicker}

${title}

+ ${body} +
`; + +/** ① 事态全景:受理刚开始,先把「这一单是什么」摆清楚 */ +export const INTAKE_OVERVIEW = SHELL( + 'RFQ 受理全景', + 'A1 · 统一受理', + '#3370ff', + `
+

询价来源

+
+
客户编码
+
询价单号
+
主题
+
+
+
+

归一结果

+
+
型号 PN
+
厂牌
+
年用量 EAU
+
目标价
+
交期
+
缺关键字段
+
+

归一由模型从询价原文抽取,逐条落任务级草稿层;放行前不入正式版。

+
`, +); + +/** ② 进展:打标之后,把判据摊开 —— 人要在数据上作判断,数据就得摆出来 */ +export const INTAKE_CLASSIFIED = SHELL( + '料号分类与判据', + 'A1 · 三分类 P-03', + '#0aa294', + `
+

分类结论

+
+
型号 PN
+
Type 标
+
分级依据
+
+
+
+

打标判据(DMN 入参)

+
+
距上次报价
+
行情稳定
+
生命周期
+
历史价命中
+
+

+ 三分类规则 P-03 是确定性决策表:同样的判据必得同样的结论,零方差零 token。 + A = 直接快答;B = 重估后报(转寻源);C = 转案处理(转替代选型)。 +

+
`, +); + +/** ③ 成果预览:验收放行前的最后一屏 —— 要放行的到底是什么,一眼看全 */ +export const INTAKE_RELEASE = SHELL( + '统一受理记录 · 待放行', + 'A1 · 成果预览', + '#7f3bf5', + `
+

本单交付

+ + + + + + + + + + + +
型号厂牌年用量目标价交期
+
+
+

分流去向

+
+
Type 标
+
依据
+
+

+ 放行通过即刻落地:草稿层合并入正式版、数据事件此刻才发射,G2 网关按 Type 分流。 + 放行是草稿态与事实态之间的那道门 —— 门前的一切都还改得动,门后的都算数了。 +

+
`, +); diff --git a/api-report/apps/api/src/modules/flow-engine/port.d.ts b/api-report/apps/api/src/modules/flow-engine/port.d.ts index 45389e6..6da7776 100644 --- a/api-report/apps/api/src/modules/flow-engine/port.d.ts +++ b/api-report/apps/api/src/modules/flow-engine/port.d.ts @@ -77,6 +77,19 @@ export type SchemaValidator = { errors: string[]; }; }; +/** + * 视图流控 —— app 注入 `interaction`(L3)。 + * flow 按步序位置推声明的模版;推的是**填好数的 HTML**,容器只渲染不执行。 + */ +export type ViewGateway = { + /** 按模版名取模版、以任务视图填数、推给前端;模版不存在就什么都不做 */ + push(input: { + taskId: TaskId; + activityId: string; + template: string; + scope: Record; + }): Promise; +}; /** 人类工具步 —— app 注入 `human-tool`(L3) */ export type CardGateway = { issue(input: { diff --git a/api-report/apps/api/src/modules/interaction/contract.d.ts b/api-report/apps/api/src/modules/interaction/contract.d.ts index 707fec0..d7e9bb0 100644 --- a/api-report/apps/api/src/modules/interaction/contract.d.ts +++ b/api-report/apps/api/src/modules/interaction/contract.d.ts @@ -51,6 +51,11 @@ export type InteractionService = { pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise; /** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */ pushViewHtml(taskId: TaskId, html: string): Promise; + /** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */ + viewOf(taskId: TaskId): Promise<{ + blocks?: readonly ViewBlock[]; + html?: string; + } | undefined>; /** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */ setPhase(taskId: TaskId, phase: InteractionPhase): Promise; phaseOf(taskId: TaskId): Promise; diff --git a/api-report/apps/api/src/modules/interaction/index.d.ts b/api-report/apps/api/src/modules/interaction/index.d.ts index 9ecd7f1..9caf8c3 100644 --- a/api-report/apps/api/src/modules/interaction/index.d.ts +++ b/api-report/apps/api/src/modules/interaction/index.d.ts @@ -7,5 +7,6 @@ export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStor export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts'; export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts'; export { CONTROL_HOLDERS, checkTemplate, handoverHappens, inputControl, INTERACTION_PHASES, outputControl, templateIsSafe, } from './internal/domain/control-handover.ts'; +export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts'; export { createInteractionStore } from './internal/adapter/interaction-store.ts'; export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts'; diff --git a/api-report/apps/api/src/modules/interaction/port.d.ts b/api-report/apps/api/src/modules/interaction/port.d.ts index 2408c70..e2c4ad5 100644 --- a/api-report/apps/api/src/modules/interaction/port.d.ts +++ b/api-report/apps/api/src/modules/interaction/port.d.ts @@ -16,6 +16,10 @@ export type InteractionStore = { blocks?: readonly ViewBlock[]; html?: string; }): Promise; + getView(taskId: TaskId): Promise<{ + blocks?: readonly ViewBlock[]; + html?: string; + } | undefined>; setPhase(taskId: TaskId, phase: InteractionPhase): Promise; phaseOf(taskId: TaskId): Promise; }; diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index f579bc7..23cae0e 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -1000,6 +1000,7 @@ export declare const taskDetailSchema: z.ZodObject<{ }, z.core.$strip>], "kind">; }, z.core.$strip>>; }, z.core.$strip>], "t">>; + viewHtml: z.ZodOptional; log: z.ZodArray], "kind">; }, z.core.$strip>>; }, z.core.$strip>], "t">>; + viewHtml: z.ZodOptional; log: z.ZodArray; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -180,6 +181,7 @@ export declare const flowStepSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -213,6 +215,7 @@ export declare const flowStepSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -241,6 +244,7 @@ export declare const flowStepSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -273,6 +277,7 @@ export declare const flowModelSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -303,6 +308,7 @@ export declare const flowModelSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -336,6 +342,7 @@ export declare const flowModelSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -364,6 +371,7 @@ export declare const flowModelSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -526,6 +534,7 @@ export declare const businessModelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -556,6 +565,7 @@ export declare const businessModelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -589,6 +599,7 @@ export declare const businessModelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -617,6 +628,7 @@ export declare const businessModelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -702,6 +714,7 @@ export declare const manifestAssetsSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -732,6 +745,7 @@ export declare const manifestAssetsSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -765,6 +779,7 @@ export declare const manifestAssetsSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -793,6 +808,7 @@ export declare const manifestAssetsSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -1054,6 +1070,7 @@ export declare const activityManifestSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -1084,6 +1101,7 @@ export declare const activityManifestSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -1117,6 +1135,7 @@ export declare const activityManifestSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; @@ -1145,6 +1164,7 @@ export declare const activityManifestSchema: z.ZodObject<{ fields: z.ZodRecord; }, z.core.$strip>>; when: z.ZodOptional; + view: z.ZodOptional; failure: z.ZodObject<{ retry: z.ZodObject<{ times: z.ZodNumber; diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index be5154e..caad80c 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -82,17 +82,19 @@ export function registerDeskRoutes(app: Hono, container: Container): voi const taskId = taskIdSchema.parse(c.req.param('taskId')); const task = await container.task.get(taskId); - const [workOrders, chat, lines] = await Promise.all([ + const [workOrders, chat, lines, pushed] = await Promise.all([ container.humanTool.openWorkOrders(taskId), container.interaction.timeline(taskId), container.memory.taskLog(taskId), + container.interaction.viewOf(taskId), ]); const cards = await container.humanTool.chatCards(taskId); // 对话流 = 时间线 + 对话卡族,按时间全序 —— 与任务记忆同源、不同筛选 const merged: ChatMessage[] = [...chat, ...cards].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)); - // 视图区:活动推送的结构化块(尚无推送时给一份进度概览,不留空屏) + // 视图区:活动推的 HTML 优先(它是活动按局面组织的完整一屏); + // 尚无推送时给一份进度概览,不留空屏 const view: ViewBlock[] = [ { t: 'kv', @@ -119,7 +121,8 @@ export function registerDeskRoutes(app: Hono, container: Container): voi deliverables: [], workOrders, chat: merged, - view, + view: pushed?.blocks !== undefined ? [...pushed.blocks] : view, + ...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }), log: lines.map((l) => ({ id: l.id, at: l.at, diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 95b78e1..44a1344 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -10,6 +10,7 @@ import type { LedgerService } from '@modules/ledger'; import type { MemoryService } from '@modules/memory'; import type { ToolboxService, ToolInvoker } from '@modules/toolbox'; import type { HumanToolService } from '@modules/human-tool'; +import { renderView } from '@modules/interaction'; import type { InteractionService } from '@modules/interaction'; import type { MessageService } from '@modules/message'; import type { OrgService } from '@modules/org'; @@ -285,6 +286,31 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { data: taskData, tools: { ...toolGateway, invoke: (i) => toolGateway.invoke(i) }, schemas: deps.schemas, + views: { + /* + * 按名取模版 → 填数 → 推给前端。 + * 模版取自**该任务装载时的资产版本**(经 registry),不是「当前最新」—— + * 在途任务按其启动时版本跑完,呈现也该跟着那一版。 + */ + async push({ taskId, activityId, template, scope }) { + const manifest = await deps.registry.manifestOf(activityId); + const found = manifest.assets.viewTemplates.find((t) => t.name === template); + if (found === undefined) return; + + /* + * 视图作用域 = 任务视图 + **本单自己写下的行**。 + * 两者要分得开:任务视图里的 `域.对象` 是正式版 + 我的草稿的合并结果, + * 拿它画「本单交付」会把别单放行过的记录一并列出来。 + * 本单的行放在 `mine:域.对象` 下 —— 整键,取值不歧义。 + */ + const own: Record = {}; + for (const draft of await deps.ledger.taskDrafts(taskId)) { + const key = `mine:${draft.domain}.${draft.object}`; + (own[key] ??= []).push({ ...draft.key, ...draft.patch }); + } + await deps.interaction.pushViewHtml(taskId, renderView(found.html, { ...scope, ...own })); + }, + }, llm: { async generate(input) { // LLM 在 flow 里只以「被调用的工具」出现,不是调度者 diff --git a/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts b/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts index 4a58382..305f8e4 100644 --- a/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts +++ b/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts @@ -165,6 +165,14 @@ export function lastSelectedPorts(position: FlowPosition): Record)[path]; + if (direct !== undefined) return direct; + } return path.split('.').reduce((acc, seg) => { if (acc === null || acc === undefined || typeof acc !== 'object') return undefined; if (Array.isArray(acc)) { diff --git a/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts b/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts index 8177b48..373f526 100644 --- a/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts +++ b/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts @@ -11,6 +11,7 @@ import type { ListenerRegistrar, LlmStepGateway, SchemaValidator, + ViewGateway, MemoryRecorder, PredicateEvaluator, TaskContext, @@ -38,6 +39,7 @@ export type FlowRunnerDeps = { readonly tools: ToolStepInvoker; readonly llm: LlmStepGateway; readonly schemas: SchemaValidator; + readonly views: ViewGateway; readonly cards: CardGateway; readonly listeners: ListenerRegistrar; readonly memory: MemoryRecorder; @@ -77,6 +79,28 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService { }); } + /** + * 按步序位置推视图 —— 声明了 `view` 才推,推的是这一步跑完后的**任务视图**。 + * 推送失败不该把步拖垮:呈现是投影,投影出问题不改变已经发生的事实。 + */ + async function pushStepView(taskId: TaskId, step: FlowStep, activityId: string): Promise { + if (step.view === undefined) return; + try { + await deps.views.push({ taskId, activityId, template: step.view, scope: await deps.data.view(taskId) }); + } catch (error) { + /* + * 呈现失败只丢这一帧画面,不影响步序推进 —— 但**必须留痕**: + * 静默吞掉会让「视图区一直空着」变成一桩查不出原因的怪事。 + */ + await deps.memory.append({ + taskId, + kind: 'engine-transition', + title: `视图推送失败(不影响推进):${error instanceof Error ? error.message : String(error)}`, + detail: { step: step.name, template: step.view }, + }); + } + } + /** 回传协议 → 出参:取 `values`;非回传形状的人事件按原样当出参 */ function replyValuesOf(payload: Record): Record { const values = payload['values']; @@ -126,6 +150,7 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService { }); if (!result.ok) return handleFailure(taskId, step, index, position, attempt, result.error?.message ?? '工具失败'); if (result.output !== undefined) await writeOutput(taskId, step, result.output); + await pushStepView(taskId, step, context.activityId); const next = markStep(position, index, { state: 'done', attempt, ...(result.output === undefined ? {} : { output: result.output }) }); await deps.state.save(taskId, (await loaded(taskId)).model, next); return { position: next, status: 'running' }; @@ -164,6 +189,7 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService { const output = parsed as Record; await writeOutput(taskId, step, output); + await pushStepView(taskId, step, context.activityId); const next = markStep(position, index, { state: 'done', attempt, output }); await deps.state.save(taskId, (await loaded(taskId)).model, next); return { position: next, status: 'running' }; @@ -198,6 +224,8 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService { keyFilter: { cardId: card.id }, deadline: step.deadline, }); + // 发卡的同时把全景推上去:人要在数据上作判断,数据就得摆在卡旁边 + await pushStepView(taskId, step, context.activityId); const next = markStep(position, index, { state: 'awaiting', attempt }); await deps.state.save(taskId, (await loaded(taskId)).model, next); return { diff --git a/apps/api/src/modules/flow-engine/port.ts b/apps/api/src/modules/flow-engine/port.ts index 2135071..83ef4c6 100644 --- a/apps/api/src/modules/flow-engine/port.ts +++ b/apps/api/src/modules/flow-engine/port.ts @@ -64,6 +64,15 @@ export type SchemaValidator = { validate(schema: Record, value: unknown): { ok: boolean; errors: string[] }; }; +/** + * 视图流控 —— app 注入 `interaction`(L3)。 + * flow 按步序位置推声明的模版;推的是**填好数的 HTML**,容器只渲染不执行。 + */ +export type ViewGateway = { + /** 按模版名取模版、以任务视图填数、推给前端;模版不存在就什么都不做 */ + push(input: { taskId: TaskId; activityId: string; template: string; scope: Record }): Promise; +}; + /** 人类工具步 —— app 注入 `human-tool`(L3) */ export type CardGateway = { issue(input: { diff --git a/apps/api/src/modules/interaction/__tests__/view-render.test.ts b/apps/api/src/modules/interaction/__tests__/view-render.test.ts new file mode 100644 index 0000000..e7354c8 --- /dev/null +++ b/apps/api/src/modules/interaction/__tests__/view-render.test.ts @@ -0,0 +1,110 @@ +/** 视图模版填数 —— domain 测试零 mock(AM 07:模版是内容不是代码) */ +import { describe, expect, it } from 'vitest'; +import { displayValue, escapeHtml, renderView, resolveSlot } from '../internal/domain/view-render.ts'; +import { templateIsSafe } from '../internal/domain/control-handover.ts'; + +describe('取值与展示', () => { + it('点号下行 + 数组下标 —— 与步声明的写法一致', () => { + const scope = { records: [{ pn: 'LM358DR' }], n: 3 }; + expect(resolveSlot(scope, 'records.0.pn')).toBe('LM358DR'); + expect(resolveSlot(scope, 'n')).toBe(3); + expect(resolveSlot(scope, 'nope.deep')).toBeUndefined(); + }); + + it('空值显示为「—」,免得画面留空洞', () => { + expect(displayValue(undefined)).toBe('—'); + expect(displayValue('')).toBe('—'); + expect(displayValue(null)).toBe('—'); + }); + + it('数组显示条数,布尔显示是/否,小数收敛', () => { + expect(displayValue([1, 2, 3])).toBe('3'); + expect(displayValue(true)).toBe('是'); + expect(displayValue(0.123456789)).toBe('0.1235'); + }); +}); + +describe('填数只做取值与转义 —— 模版不得成为代码', () => { + it('填入的值被转义,注不进标签', () => { + const html = ''; + const out = renderView(html, { x: '' }); + // 标签没能成型:尖括号全被转义,留下的是惰性文本 + expect(out).not.toContain('<img src=x onerror=alert(1)>'); + }); + + it('净化门查的是**作者写的模版**,不是填完数据的成品', () => { + // 模版本身必须无脚本、无事件处理器、无外链 + expect(templateIsSafe('')).toBe(true); + expect(templateIsSafe('')).toBe(false); + // 成品里出现「onerror=」这串字面文本是转义后的惰性文本,不该再拿模版规则去卡它 —— + // 否则任何正文提到事件处理器的业务数据都会被误伤。安全由「模版过门 + 沙箱容器」两道保证。 + expect(renderView('', { x: 'onclick=1' })).toBe('onclick=1'); + }); + + it('未命中的槽位填「—」,不把占位文字漏到成品上', () => { + expect(renderView('占位', {})).toBe('—'); + }); + + it('填充消费槽位 —— 交出去的是干净 HTML,不带模板属性', () => { + expect(renderView('', { x: 1 })).toBe('1'); + }); +}); + +describe('行模版 —— 逐条清单不写死条数', () => { + const html = '
'; + + it('按数组逐项复制,行内槽位相对该行取值', () => { + const out = renderView(html, { + records: [ + { pn: 'A1', eau: 100 }, + { pn: 'B2', eau: 200 }, + ], + }); + expect(out).toContain('A1'); + expect(out).toContain('B2'); + expect(out).toContain('200'); + }); + + it('空数组渲染成空容器,不留一行占位', () => { + const out = renderView(html, { records: [] }); + expect(out).toBe('
'); + }); + + it('行先展开再填标量槽 —— 顺序反了行内会被外层作用域填掉', () => { + const out = renderView('
', { + rs: [{ v: '行内' }], + v: '外层', + }); + expect(out).toContain('
  • 行内
  • '); + expect(out).toContain('外层'); + }); +}); + +describe('转义', () => { + it('四个字符各归其位', () => { + expect(escapeHtml('&')).toBe('<a href="x">&</a>'); + }); +}); + +describe('整键优先 —— 台账地址本身带点号', () => { + const scope = { + 'quote_intake.record': [{ pn: 'LM358DR', eau: 12000 }], + quote_intake: { record: [{ pn: '不该取到这个' }] }, + a: { b: 'nested' }, + }; + + it('字面整键存在时取它,不拆成两级', () => { + const rows = resolveSlot(scope, 'quote_intake.record'); + expect(Array.isArray(rows) && (rows[0] as { pn: string }).pn).toBe('LM358DR'); + }); + + it('没有整键才按点号下行', () => { + expect(resolveSlot(scope, 'a.b')).toBe('nested'); + }); + + it('行模版用地址键取表', () => { + const out = renderView('', scope); + expect(out).toContain('LM358DR'); + }); +}); diff --git a/apps/api/src/modules/interaction/contract.ts b/apps/api/src/modules/interaction/contract.ts index 2f4db3d..aeb4c36 100644 --- a/apps/api/src/modules/interaction/contract.ts +++ b/apps/api/src/modules/interaction/contract.ts @@ -41,6 +41,8 @@ export type InteractionService = { pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise; /** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */ pushViewHtml(taskId: TaskId, html: string): Promise; + /** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */ + viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>; /** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */ setPhase(taskId: TaskId, phase: InteractionPhase): Promise; diff --git a/apps/api/src/modules/interaction/index.ts b/apps/api/src/modules/interaction/index.ts index 15cd331..ecc88af 100644 --- a/apps/api/src/modules/interaction/index.ts +++ b/apps/api/src/modules/interaction/index.ts @@ -27,6 +27,7 @@ export { outputControl, templateIsSafe, } from './internal/domain/control-handover.ts'; +export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts'; export { createInteractionStore } from './internal/adapter/interaction-store.ts'; export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts'; diff --git a/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts b/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts index 32e61a5..7532530 100644 --- a/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts +++ b/apps/api/src/modules/interaction/internal/adapter/interaction-store.ts @@ -43,6 +43,14 @@ export function createInteractionStore(db: Db): InteractionStore { set: { blocks: values.blocks, html: values.html, updatedAt: values.updatedAt }, }); }, + async getView(taskId: TaskId) { + const [row] = await db.select().from(taskViewTable).where(eq(taskViewTable.taskId, taskId)).limit(1); + if (row === undefined) return undefined; + return { + ...(row.blocks === null ? {} : { blocks: row.blocks as ViewBlock[] }), + ...(row.html === null ? {} : { html: row.html }), + }; + }, async setPhase(taskId: TaskId, phase: InteractionPhase) { await db .insert(taskViewTable) diff --git a/apps/api/src/modules/interaction/internal/domain/view-render.ts b/apps/api/src/modules/interaction/internal/domain/view-render.ts new file mode 100644 index 0000000..e50ce14 --- /dev/null +++ b/apps/api/src/modules/interaction/internal/domain/view-render.ts @@ -0,0 +1,91 @@ +/** + * 视图模版填数 —— 纯函数、零 IO(编码规范 §10.2;语义出处 AM 07)。 + * + * 「**模版是内容不是代码**:AI 按局面选模版、填数据、决定下一屏呈现什么。」 + * 所以填数只做**取值与转义**两件事:没有表达式、没有分支、没有循环 —— + * 一旦模版能算,它就成了代码,容器的「无脚本执行权」也就形同虚设。 + * + * 约定:`data-slot="路径"` 的元素,内容替换为作用域上该路径的值。 + * 路径按点号下行、数组下标按数字段(`records.0.pn`),与步声明的取值写法一致。 + */ + +/** 只在**文本位置**插值,故只需转义这四个字符;属性位不开放插值 */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function resolveSlot(scope: unknown, path: string): unknown { + /* + * 整键优先:作用域里有些键**本身带点号**(台账地址 `域.对象`)。 + * 先按字面整键找,找不到再按点号下行 —— 反过来会把地址键拆成两级、永远找不到。 + */ + if (typeof scope === 'object' && scope !== null && !Array.isArray(scope)) { + const direct = (scope as Record)[path]; + if (direct !== undefined) return direct; + } + return path.split('.').reduce((acc, seg) => { + if (acc === null || acc === undefined || typeof acc !== 'object') return undefined; + if (Array.isArray(acc)) { + const index = Number(seg); + return Number.isInteger(index) ? acc[index] : undefined; + } + return (acc as Record)[seg]; + }, scope); +} + +/** 值 → 展示文本:空值显示为「—」,免得画面上留一串空洞 */ +export function displayValue(value: unknown): string { + if (value === undefined || value === null || value === '') return '—'; + if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4))); + if (typeof value === 'boolean') return value ? '是' : '否'; + if (Array.isArray(value)) return String(value.length); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g; + +/** + * 把作用域填进模版的槽位。 + * + * 填充**消费槽位**:填完就把 `data-slot` 摘掉 —— 一来第二遍不会再拿外层作用域 + * 把行内已填好的值冲掉,二来交出去的是干净 HTML,不是带模板属性的半成品。 + * 未命中的槽位填「—」而不是留原样:模版里的占位文字漏到成品上比空着更糟。 + */ +export function renderTemplate(html: string, scope: Readonly>): string { + return html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => { + const attrs = `${before}${after}`.replace(/\s+/g, ' ').trimEnd(); + const text = escapeHtml(displayValue(resolveSlot(scope, path))); + return `<${tag}${attrs === '' ? '' : ` ${attrs.trim()}`}>${text}`; + }); +} + +/** + * 行模版:`data-rows="路径"` 的元素,按数组逐项复制其内部结构。 + * 每行内部的 `data-slot` 相对**该行对象**取值 —— 这样「逐条清单」不必写死条数。 + */ +const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g; + +export function renderRows(html: string, scope: Readonly>): string { + return html.replace(ROWS, (_all, tag: string, before: string, path: string, after: string, inner: string) => { + const attrs = `${before}${after}`.replace(/\s+/g, ' ').trim(); + const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`; + const value = resolveSlot(scope, path); + const rows = Array.isArray(value) ? value : []; + const body = rows + .map((row) => + renderTemplate(inner, (typeof row === 'object' && row !== null ? row : { value: row }) as Record), + ) + .join(''); + return `${open}${body}`; + }); +} + +/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */ +export function renderView(html: string, scope: Readonly>): string { + return renderTemplate(renderRows(html, scope), scope); +} diff --git a/apps/api/src/modules/interaction/internal/usecase/interaction-service.ts b/apps/api/src/modules/interaction/internal/usecase/interaction-service.ts index a9a48de..946323b 100644 --- a/apps/api/src/modules/interaction/internal/usecase/interaction-service.ts +++ b/apps/api/src/modules/interaction/internal/usecase/interaction-service.ts @@ -68,6 +68,10 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact broadcast({ kind: 'view-html', taskId, html }); }, + async viewOf(taskId) { + return deps.store.getView(taskId); + }, + async setPhase(taskId: TaskId, phase: InteractionPhase) { await deps.store.setPhase(taskId, phase); const output = outputControl(phase); diff --git a/apps/api/src/modules/interaction/port.ts b/apps/api/src/modules/interaction/port.ts index 7775021..2485dc6 100644 --- a/apps/api/src/modules/interaction/port.ts +++ b/apps/api/src/modules/interaction/port.ts @@ -11,6 +11,7 @@ export type InteractionStore = { appendMessage(taskId: TaskId, message: ChatMessage): Promise; listMessages(taskId: TaskId): Promise; putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }): Promise; + getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>; setPhase(taskId: TaskId, phase: InteractionPhase): Promise; phaseOf(taskId: TaskId): Promise; }; diff --git a/apps/web/src/app/styles/theme.css b/apps/web/src/app/styles/theme.css index 9c8c495..0bcb44b 100644 --- a/apps/web/src/app/styles/theme.css +++ b/apps/web/src/app/styles/theme.css @@ -59,3 +59,18 @@ body { outline-offset: 2px; border-radius: 8px; } + +/* + * 视图区容器 —— 活动推送 HTML 的沙箱画框(AM 07)。 + * 这是**平台本体**的件:活动只给内容,尺寸与边框归系统。 + * iframe 无脚本执行权(sandbox=""),因此内容自带样式、不继承工作台 CSS。 + */ +.jc-view-frame { + display: block; + width: 100%; + min-height: 420px; + height: 100%; + border: 0; + border-radius: 10px; + background: #f7f8fa; +} diff --git a/apps/web/src/features/desk-task/internal/ViewRegion.tsx b/apps/web/src/features/desk-task/internal/ViewRegion.tsx index 947b362..bfaa445 100644 --- a/apps/web/src/features/desk-task/internal/ViewRegion.tsx +++ b/apps/web/src/features/desk-task/internal/ViewRegion.tsx @@ -9,7 +9,7 @@ import type { TaskDetail, ViewBlock, WorkOrder } from '@contracts'; import { ACTION_EFFECT_LABEL, WORK_ORDER_MODE_LABEL } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; import ScrollArea from '@web-shared/ScrollArea.tsx'; -import { ViewBlocks } from '@ui'; +import { ViewBlocks, ViewContainer } from '@ui'; type Tab = 'todo' | 'result' | 'detail' | 'log'; @@ -65,7 +65,16 @@ export function ViewRegion({ detail, onReply }: ViewRegionProps) { open.map((order) => ) ))} - {tab === 'result' && } + {/* + 活动推的 HTML 优先:那是它按当下局面组织的**完整一屏**; + 没推 HTML 时退回结构化块。两者都经 ViewContainer 渲染 —— feature 不碰 innerHTML。 + */} + {tab === 'result' && + (detail.viewHtml === undefined ? ( + + ) : ( + + ))} {tab === 'detail' && (
    diff --git a/packages/contracts/src/api/workbench.ts b/packages/contracts/src/api/workbench.ts index 6c7e875..b8411ac 100644 --- a/packages/contracts/src/api/workbench.ts +++ b/packages/contracts/src/api/workbench.ts @@ -64,6 +64,8 @@ export const taskDetailSchema = z.object({ workOrders: z.array(workOrderSchema), chat: z.array(chatMessageSchema), view: z.array(viewBlockSchema), + /** 活动推送的视图 HTML —— 容器沙箱渲染,无脚本执行权(AM 07) */ + viewHtml: z.string().optional(), log: z.array( z.object({ id: z.string(), diff --git a/packages/contracts/src/manifest.ts b/packages/contracts/src/manifest.ts index d9cba18..70d7071 100644 --- a/packages/contracts/src/manifest.ts +++ b/packages/contracts/src/manifest.ts @@ -108,6 +108,11 @@ const stepBase = { .optional(), /** 条件跳步:任务数据上的确定性谓词(S-FEEL),零 LLM */ when: z.string().optional(), + /** + * 这一步呈现哪张视图模版(`assets.viewTemplates` 里的 name)。 + * 「flow:按**步序位置**推送声明的模版」(AM 07)——推什么由声明给定,不由临场决定。 + */ + view: z.string().optional(), failure: stepFailurePolicySchema, };