forked from ChaiHao/BPOS-MVP
汇报流:活动按步序位置推视图,容器沙箱渲染
「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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||||
import { activityIdSchema } from '@contracts';
|
import { activityIdSchema } from '@contracts';
|
||||||
|
import { INTAKE_CLASSIFIED, INTAKE_OVERVIEW, INTAKE_RELEASE } from './views/report.ts';
|
||||||
|
|
||||||
const INTAKE_DOMAIN = 'quote_intake';
|
const INTAKE_DOMAIN = 'quote_intake';
|
||||||
const ACTIVITY_ID = activityIdSchema.parse('quote-intake');
|
const ACTIVITY_ID = activityIdSchema.parse('quote-intake');
|
||||||
@@ -40,7 +41,7 @@ export const manifest: ActivityManifest = {
|
|||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'A1 · RFQ 需求分析',
|
name: 'A1 · RFQ 需求分析',
|
||||||
packageVersion: '0.1.5',
|
packageVersion: '0.1.7',
|
||||||
category: '统一报价',
|
category: '统一报价',
|
||||||
paradigm: 'flow',
|
paradigm: 'flow',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
@@ -223,6 +224,7 @@ export const manifest: ActivityManifest = {
|
|||||||
missingKeyFields: { type: 'integer' },
|
missingKeyFields: { type: 'integer' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
view: 'intake-overview',
|
||||||
failure: { retry: { times: 2, backoff: 'PT30S' }, fallback: { kind: 'human', workOrder: 'execution' } },
|
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' },
|
fields: { type_tag: 'type', basis: 'play' },
|
||||||
},
|
},
|
||||||
tool: 'quote-intake.classify',
|
tool: 'quote-intake.classify',
|
||||||
|
view: 'intake-classified',
|
||||||
failure: { retry: { times: 1, backoff: 'PT10S' }, fallback: { kind: 'blocked' } },
|
failure: { retry: { times: 1, backoff: 'PT10S' }, fallback: { kind: 'blocked' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -279,6 +282,7 @@ export const manifest: ActivityManifest = {
|
|||||||
input: {},
|
input: {},
|
||||||
workOrder: 'acceptance',
|
workOrder: 'acceptance',
|
||||||
card: { title: '统一受理记录(打标)请验收' },
|
card: { title: '统一受理记录(打标)请验收' },
|
||||||
|
view: 'intake-release',
|
||||||
deadline: 'P1D',
|
deadline: 'P1D',
|
||||||
failure: { retry: { times: 0, backoff: 'PT30S' }, fallback: { kind: 'blocked' } },
|
failure: { retry: { times: 0, backoff: 'PT30S' }, fallback: { kind: 'blocked' } },
|
||||||
},
|
},
|
||||||
@@ -286,11 +290,11 @@ export const manifest: ActivityManifest = {
|
|||||||
parallelGroups: [],
|
parallelGroups: [],
|
||||||
},
|
},
|
||||||
skills: [],
|
skills: [],
|
||||||
|
// 三张模版对应三种局面(事态全景 / 进展 / 成果预览),由步序声明按位置推送
|
||||||
viewTemplates: [
|
viewTemplates: [
|
||||||
{
|
{ name: 'intake-overview', html: INTAKE_OVERVIEW },
|
||||||
name: 'intake-summary',
|
{ name: 'intake-classified', html: INTAKE_CLASSIFIED },
|
||||||
html: '<div class="jc-card"><h3>统一受理记录</h3><p>已归一 <b data-slot="count"></b> 条</p></div>',
|
{ name: 'intake-release', html: INTAKE_RELEASE },
|
||||||
},
|
|
||||||
],
|
],
|
||||||
cardSchemas: {},
|
cardSchemas: {},
|
||||||
distillHooks: [
|
distillHooks: [
|
||||||
|
|||||||
@@ -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 => `
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 18px;
|
||||||
|
font: 13px/1.6 -apple-system, "PingFang SC", "Noto Sans SC", "Segoe UI", sans-serif;
|
||||||
|
color: #1f2933; background: #f7f8fa;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 720px; margin: 0 auto; }
|
||||||
|
.hd { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 14px; }
|
||||||
|
.hd .bar { width: 4px; align-self: stretch; border-radius: 2px; background: ${accent}; }
|
||||||
|
.hd .kick { font-size: 11px; letter-spacing: .08em; color: #6a7686; text-transform: uppercase; }
|
||||||
|
.hd h1 { margin: 2px 0 0; font-size: 17px; font-weight: 650; letter-spacing: .01em; }
|
||||||
|
.card { background: #fff; border: 1px solid #e6e9ee; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
|
||||||
|
.card h2 { margin: 0 0 10px; font-size: 12px; font-weight: 600; color: #6a7686; letter-spacing: .04em; }
|
||||||
|
.kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px 18px; }
|
||||||
|
.kv div { min-width: 0; }
|
||||||
|
.kv em { display: block; font-style: normal; font-size: 11px; color: #8b95a3; margin-bottom: 2px; }
|
||||||
|
.kv b { font-size: 14px; font-weight: 600; word-break: break-all; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th { text-align: left; font-weight: 600; color: #6a7686; padding: 6px 8px; border-bottom: 1px solid #e6e9ee; white-space: nowrap; }
|
||||||
|
td { padding: 7px 8px; border-bottom: 1px solid #f0f2f5; }
|
||||||
|
tr:last-child td { border-bottom: 0; }
|
||||||
|
.mono { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px; }
|
||||||
|
.tag { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
|
||||||
|
background: ${accent}1a; color: ${accent}; }
|
||||||
|
.note { font-size: 11px; color: #8b95a3; margin-top: 10px; line-height: 1.7; }
|
||||||
|
.empty { color: #a8b0bb; font-size: 12px; padding: 6px 0; }
|
||||||
|
</style>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="hd"><span class="bar"></span><div><div class="kick">${kicker}</div><h1>${title}</h1></div></div>
|
||||||
|
${body}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
/** ① 事态全景:受理刚开始,先把「这一单是什么」摆清楚 */
|
||||||
|
export const INTAKE_OVERVIEW = SHELL(
|
||||||
|
'RFQ 受理全景',
|
||||||
|
'A1 · 统一受理',
|
||||||
|
'#3370ff',
|
||||||
|
`<div class="card">
|
||||||
|
<h2>询价来源</h2>
|
||||||
|
<div class="kv">
|
||||||
|
<div><em>客户编码</em><b class="mono" data-slot="customer_code"></b></div>
|
||||||
|
<div><em>询价单号</em><b class="mono" data-slot="rfqRef"></b></div>
|
||||||
|
<div><em>主题</em><b data-slot="subject"></b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>归一结果</h2>
|
||||||
|
<div class="kv">
|
||||||
|
<div><em>型号 PN</em><b class="mono" data-slot="pn"></b></div>
|
||||||
|
<div><em>厂牌</em><b data-slot="brand"></b></div>
|
||||||
|
<div><em>年用量 EAU</em><b data-slot="eau"></b></div>
|
||||||
|
<div><em>目标价</em><b data-slot="target_price"></b></div>
|
||||||
|
<div><em>交期</em><b data-slot="lead_time"></b></div>
|
||||||
|
<div><em>缺关键字段</em><b data-slot="missing_key_fields"></b></div>
|
||||||
|
</div>
|
||||||
|
<p class="note">归一由模型从询价原文抽取,逐条落任务级草稿层;放行前不入正式版。</p>
|
||||||
|
</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** ② 进展:打标之后,把判据摊开 —— 人要在数据上作判断,数据就得摆出来 */
|
||||||
|
export const INTAKE_CLASSIFIED = SHELL(
|
||||||
|
'料号分类与判据',
|
||||||
|
'A1 · 三分类 P-03',
|
||||||
|
'#0aa294',
|
||||||
|
`<div class="card">
|
||||||
|
<h2>分类结论</h2>
|
||||||
|
<div class="kv">
|
||||||
|
<div><em>型号 PN</em><b class="mono" data-slot="pn"></b></div>
|
||||||
|
<div><em>Type 标</em><b class="tag" data-slot="type_tag"></b></div>
|
||||||
|
<div><em>分级依据</em><b data-slot="basis"></b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>打标判据(DMN 入参)</h2>
|
||||||
|
<div class="kv">
|
||||||
|
<div><em>距上次报价</em><b data-slot="history_days"></b></div>
|
||||||
|
<div><em>行情稳定</em><b data-slot="market_stable"></b></div>
|
||||||
|
<div><em>生命周期</em><b data-slot="lifecycle"></b></div>
|
||||||
|
<div><em>历史价命中</em><b data-slot="history_hit"></b></div>
|
||||||
|
</div>
|
||||||
|
<p class="note">
|
||||||
|
三分类规则 P-03 是确定性决策表:同样的判据必得同样的结论,零方差零 token。
|
||||||
|
A = 直接快答;B = 重估后报(转寻源);C = 转案处理(转替代选型)。
|
||||||
|
</p>
|
||||||
|
</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** ③ 成果预览:验收放行前的最后一屏 —— 要放行的到底是什么,一眼看全 */
|
||||||
|
export const INTAKE_RELEASE = SHELL(
|
||||||
|
'统一受理记录 · 待放行',
|
||||||
|
'A1 · 成果预览',
|
||||||
|
'#7f3bf5',
|
||||||
|
`<div class="card">
|
||||||
|
<h2>本单交付</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>型号</th><th>厂牌</th><th>年用量</th><th>目标价</th><th>交期</th></tr></thead>
|
||||||
|
<tbody data-rows="mine:quote_intake.record">
|
||||||
|
<tr>
|
||||||
|
<td class="mono" data-slot="pn"></td>
|
||||||
|
<td data-slot="brand"></td>
|
||||||
|
<td data-slot="eau"></td>
|
||||||
|
<td data-slot="target_price"></td>
|
||||||
|
<td data-slot="lead_time"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>分流去向</h2>
|
||||||
|
<div class="kv">
|
||||||
|
<div><em>Type 标</em><b class="tag" data-slot="type_tag"></b></div>
|
||||||
|
<div><em>依据</em><b data-slot="basis"></b></div>
|
||||||
|
</div>
|
||||||
|
<p class="note">
|
||||||
|
放行通过即刻落地:草稿层合并入正式版、数据事件此刻才发射,G2 网关按 Type 分流。
|
||||||
|
放行是草稿态与事实态之间的那道门 —— 门前的一切都还改得动,门后的都算数了。
|
||||||
|
</p>
|
||||||
|
</div>`,
|
||||||
|
);
|
||||||
@@ -77,6 +77,19 @@ export type SchemaValidator = {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 视图流控 —— app 注入 `interaction`(L3)。
|
||||||
|
* flow 按步序位置推声明的模版;推的是**填好数的 HTML**,容器只渲染不执行。
|
||||||
|
*/
|
||||||
|
export type ViewGateway = {
|
||||||
|
/** 按模版名取模版、以任务视图填数、推给前端;模版不存在就什么都不做 */
|
||||||
|
push(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
activityId: string;
|
||||||
|
template: string;
|
||||||
|
scope: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
|
};
|
||||||
/** 人类工具步 —— app 注入 `human-tool`(L3) */
|
/** 人类工具步 —— app 注入 `human-tool`(L3) */
|
||||||
export type CardGateway = {
|
export type CardGateway = {
|
||||||
issue(input: {
|
issue(input: {
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export type InteractionService = {
|
|||||||
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
||||||
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
||||||
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
|
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
|
||||||
|
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
|
||||||
|
viewOf(taskId: TaskId): Promise<{
|
||||||
|
blocks?: readonly ViewBlock[];
|
||||||
|
html?: string;
|
||||||
|
} | undefined>;
|
||||||
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
||||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
||||||
phaseOf(taskId: TaskId): Promise<InteractionPhase>;
|
phaseOf(taskId: TaskId): Promise<InteractionPhase>;
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStor
|
|||||||
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
|
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
|
||||||
export { createInteractionService, visibleTo } 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 { 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 { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
||||||
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export type InteractionStore = {
|
|||||||
blocks?: readonly ViewBlock[];
|
blocks?: readonly ViewBlock[];
|
||||||
html?: string;
|
html?: string;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
getView(taskId: TaskId): Promise<{
|
||||||
|
blocks?: readonly ViewBlock[];
|
||||||
|
html?: string;
|
||||||
|
} | undefined>;
|
||||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
||||||
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1000,6 +1000,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>], "kind">;
|
}, z.core.$strip>], "kind">;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>], "t">>;
|
}, z.core.$strip>], "t">>;
|
||||||
|
viewHtml: z.ZodOptional<z.ZodString>;
|
||||||
log: z.ZodArray<z.ZodObject<{
|
log: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
at: z.ZodString;
|
at: z.ZodString;
|
||||||
@@ -1883,6 +1884,7 @@ export declare const taskDetailRoute: {
|
|||||||
}, z.core.$strip>], "kind">;
|
}, z.core.$strip>], "kind">;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>], "t">>;
|
}, z.core.$strip>], "t">>;
|
||||||
|
viewHtml: z.ZodOptional<z.ZodString>;
|
||||||
log: z.ZodArray<z.ZodObject<{
|
log: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
at: z.ZodString;
|
at: z.ZodString;
|
||||||
|
|||||||
+20
@@ -150,6 +150,7 @@ export declare const flowStepSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -180,6 +181,7 @@ export declare const flowStepSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -213,6 +215,7 @@ export declare const flowStepSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -241,6 +244,7 @@ export declare const flowStepSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -273,6 +277,7 @@ export declare const flowModelSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -303,6 +308,7 @@ export declare const flowModelSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -336,6 +342,7 @@ export declare const flowModelSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -364,6 +371,7 @@ export declare const flowModelSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -526,6 +534,7 @@ export declare const businessModelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -556,6 +565,7 @@ export declare const businessModelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -589,6 +599,7 @@ export declare const businessModelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -617,6 +628,7 @@ export declare const businessModelSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -702,6 +714,7 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -732,6 +745,7 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -765,6 +779,7 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -793,6 +808,7 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -1054,6 +1070,7 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -1084,6 +1101,7 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -1117,6 +1135,7 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
@@ -1145,6 +1164,7 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
fields: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
when: z.ZodOptional<z.ZodString>;
|
when: z.ZodOptional<z.ZodString>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
failure: z.ZodObject<{
|
failure: z.ZodObject<{
|
||||||
retry: z.ZodObject<{
|
retry: z.ZodObject<{
|
||||||
times: z.ZodNumber;
|
times: z.ZodNumber;
|
||||||
|
|||||||
@@ -82,17 +82,19 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
||||||
const task = await container.task.get(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.humanTool.openWorkOrders(taskId),
|
||||||
container.interaction.timeline(taskId),
|
container.interaction.timeline(taskId),
|
||||||
container.memory.taskLog(taskId),
|
container.memory.taskLog(taskId),
|
||||||
|
container.interaction.viewOf(taskId),
|
||||||
]);
|
]);
|
||||||
const cards = await container.humanTool.chatCards(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));
|
const merged: ChatMessage[] = [...chat, ...cards].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
||||||
|
|
||||||
// 视图区:活动推送的结构化块(尚无推送时给一份进度概览,不留空屏)
|
// 视图区:活动推的 HTML 优先(它是活动按局面组织的完整一屏);
|
||||||
|
// 尚无推送时给一份进度概览,不留空屏
|
||||||
const view: ViewBlock[] = [
|
const view: ViewBlock[] = [
|
||||||
{
|
{
|
||||||
t: 'kv',
|
t: 'kv',
|
||||||
@@ -119,7 +121,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
deliverables: [],
|
deliverables: [],
|
||||||
workOrders,
|
workOrders,
|
||||||
chat: merged,
|
chat: merged,
|
||||||
view,
|
view: pushed?.blocks !== undefined ? [...pushed.blocks] : view,
|
||||||
|
...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }),
|
||||||
log: lines.map((l) => ({
|
log: lines.map((l) => ({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
at: l.at,
|
at: l.at,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { LedgerService } from '@modules/ledger';
|
|||||||
import type { MemoryService } from '@modules/memory';
|
import type { MemoryService } from '@modules/memory';
|
||||||
import type { ToolboxService, ToolInvoker } from '@modules/toolbox';
|
import type { ToolboxService, ToolInvoker } from '@modules/toolbox';
|
||||||
import type { HumanToolService } from '@modules/human-tool';
|
import type { HumanToolService } from '@modules/human-tool';
|
||||||
|
import { renderView } from '@modules/interaction';
|
||||||
import type { InteractionService } from '@modules/interaction';
|
import type { InteractionService } from '@modules/interaction';
|
||||||
import type { MessageService } from '@modules/message';
|
import type { MessageService } from '@modules/message';
|
||||||
import type { OrgService } from '@modules/org';
|
import type { OrgService } from '@modules/org';
|
||||||
@@ -285,6 +286,31 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
data: taskData,
|
data: taskData,
|
||||||
tools: { ...toolGateway, invoke: (i) => toolGateway.invoke(i) },
|
tools: { ...toolGateway, invoke: (i) => toolGateway.invoke(i) },
|
||||||
schemas: deps.schemas,
|
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<string, unknown[]> = {};
|
||||||
|
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: {
|
llm: {
|
||||||
async generate(input) {
|
async generate(input) {
|
||||||
// LLM 在 flow 里只以「被调用的工具」出现,不是调度者
|
// LLM 在 flow 里只以「被调用的工具」出现,不是调度者
|
||||||
|
|||||||
@@ -165,6 +165,14 @@ export function lastSelectedPorts(position: FlowPosition): Record<string, string
|
|||||||
* 悄悄写出一条空记录。
|
* 悄悄写出一条空记录。
|
||||||
*/
|
*/
|
||||||
export function resolvePath(source: unknown, path: string): unknown {
|
export function resolvePath(source: unknown, path: string): unknown {
|
||||||
|
/*
|
||||||
|
* 整键优先:作用域里有些键**本身带点号**(台账地址 `域.对象`)。
|
||||||
|
* 先按字面整键找,找不到再按点号下行 —— 反过来会把地址键拆成两级、永远找不到。
|
||||||
|
*/
|
||||||
|
if (typeof source === 'object' && source !== null && !Array.isArray(source)) {
|
||||||
|
const direct = (source as Record<string, unknown>)[path];
|
||||||
|
if (direct !== undefined) return direct;
|
||||||
|
}
|
||||||
return path.split('.').reduce<unknown>((acc, seg) => {
|
return path.split('.').reduce<unknown>((acc, seg) => {
|
||||||
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
|
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
|
||||||
if (Array.isArray(acc)) {
|
if (Array.isArray(acc)) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ListenerRegistrar,
|
ListenerRegistrar,
|
||||||
LlmStepGateway,
|
LlmStepGateway,
|
||||||
SchemaValidator,
|
SchemaValidator,
|
||||||
|
ViewGateway,
|
||||||
MemoryRecorder,
|
MemoryRecorder,
|
||||||
PredicateEvaluator,
|
PredicateEvaluator,
|
||||||
TaskContext,
|
TaskContext,
|
||||||
@@ -38,6 +39,7 @@ export type FlowRunnerDeps = {
|
|||||||
readonly tools: ToolStepInvoker;
|
readonly tools: ToolStepInvoker;
|
||||||
readonly llm: LlmStepGateway;
|
readonly llm: LlmStepGateway;
|
||||||
readonly schemas: SchemaValidator;
|
readonly schemas: SchemaValidator;
|
||||||
|
readonly views: ViewGateway;
|
||||||
readonly cards: CardGateway;
|
readonly cards: CardGateway;
|
||||||
readonly listeners: ListenerRegistrar;
|
readonly listeners: ListenerRegistrar;
|
||||||
readonly memory: MemoryRecorder;
|
readonly memory: MemoryRecorder;
|
||||||
@@ -77,6 +79,28 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按步序位置推视图 —— 声明了 `view` 才推,推的是这一步跑完后的**任务视图**。
|
||||||
|
* 推送失败不该把步拖垮:呈现是投影,投影出问题不改变已经发生的事实。
|
||||||
|
*/
|
||||||
|
async function pushStepView(taskId: TaskId, step: FlowStep, activityId: string): Promise<void> {
|
||||||
|
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`;非回传形状的人事件按原样当出参 */
|
/** 回传协议 → 出参:取 `values`;非回传形状的人事件按原样当出参 */
|
||||||
function replyValuesOf(payload: Record<string, unknown>): Record<string, unknown> {
|
function replyValuesOf(payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
const values = payload['values'];
|
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.ok) return handleFailure(taskId, step, index, position, attempt, result.error?.message ?? '工具失败');
|
||||||
if (result.output !== undefined) await writeOutput(taskId, step, result.output);
|
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 }) });
|
const next = markStep(position, index, { state: 'done', attempt, ...(result.output === undefined ? {} : { output: result.output }) });
|
||||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||||
return { position: next, status: 'running' };
|
return { position: next, status: 'running' };
|
||||||
@@ -164,6 +189,7 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
|
|||||||
|
|
||||||
const output = parsed as Record<string, unknown>;
|
const output = parsed as Record<string, unknown>;
|
||||||
await writeOutput(taskId, step, output);
|
await writeOutput(taskId, step, output);
|
||||||
|
await pushStepView(taskId, step, context.activityId);
|
||||||
const next = markStep(position, index, { state: 'done', attempt, output });
|
const next = markStep(position, index, { state: 'done', attempt, output });
|
||||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||||
return { position: next, status: 'running' };
|
return { position: next, status: 'running' };
|
||||||
@@ -198,6 +224,8 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
|
|||||||
keyFilter: { cardId: card.id },
|
keyFilter: { cardId: card.id },
|
||||||
deadline: step.deadline,
|
deadline: step.deadline,
|
||||||
});
|
});
|
||||||
|
// 发卡的同时把全景推上去:人要在数据上作判断,数据就得摆在卡旁边
|
||||||
|
await pushStepView(taskId, step, context.activityId);
|
||||||
const next = markStep(position, index, { state: 'awaiting', attempt });
|
const next = markStep(position, index, { state: 'awaiting', attempt });
|
||||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ export type SchemaValidator = {
|
|||||||
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
|
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视图流控 —— app 注入 `interaction`(L3)。
|
||||||
|
* flow 按步序位置推声明的模版;推的是**填好数的 HTML**,容器只渲染不执行。
|
||||||
|
*/
|
||||||
|
export type ViewGateway = {
|
||||||
|
/** 按模版名取模版、以任务视图填数、推给前端;模版不存在就什么都不做 */
|
||||||
|
push(input: { taskId: TaskId; activityId: string; template: string; scope: Record<string, unknown> }): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
/** 人类工具步 —— app 注入 `human-tool`(L3) */
|
/** 人类工具步 —— app 注入 `human-tool`(L3) */
|
||||||
export type CardGateway = {
|
export type CardGateway = {
|
||||||
issue(input: {
|
issue(input: {
|
||||||
|
|||||||
@@ -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 = '<b data-slot="x"></b>';
|
||||||
|
const out = renderView(html, { x: '<img src=x onerror=alert(1)>' });
|
||||||
|
// 标签没能成型:尖括号全被转义,留下的是惰性文本
|
||||||
|
expect(out).not.toContain('<img');
|
||||||
|
expect(out).toBe('<b><img src=x onerror=alert(1)></b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('净化门查的是**作者写的模版**,不是填完数据的成品', () => {
|
||||||
|
// 模版本身必须无脚本、无事件处理器、无外链
|
||||||
|
expect(templateIsSafe('<b data-slot="x"></b>')).toBe(true);
|
||||||
|
expect(templateIsSafe('<b onclick="x()"></b>')).toBe(false);
|
||||||
|
// 成品里出现「onerror=」这串字面文本是转义后的惰性文本,不该再拿模版规则去卡它 ——
|
||||||
|
// 否则任何正文提到事件处理器的业务数据都会被误伤。安全由「模版过门 + 沙箱容器」两道保证。
|
||||||
|
expect(renderView('<b data-slot="x"></b>', { x: 'onclick=1' })).toBe('<b>onclick=1</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未命中的槽位填「—」,不把占位文字漏到成品上', () => {
|
||||||
|
expect(renderView('<b data-slot="missing">占位</b>', {})).toBe('<b>—</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('填充消费槽位 —— 交出去的是干净 HTML,不带模板属性', () => {
|
||||||
|
expect(renderView('<b class="k" data-slot="x"></b>', { x: 1 })).toBe('<b class="k">1</b>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('行模版 —— 逐条清单不写死条数', () => {
|
||||||
|
const html = '<table data-rows="records"><tr><td data-slot="pn"></td><td data-slot="eau"></td></tr></table>';
|
||||||
|
|
||||||
|
it('按数组逐项复制,行内槽位相对该行取值', () => {
|
||||||
|
const out = renderView(html, {
|
||||||
|
records: [
|
||||||
|
{ pn: 'A1', eau: 100 },
|
||||||
|
{ pn: 'B2', eau: 200 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out).toContain('<td>A1</td>');
|
||||||
|
expect(out).toContain('<td>B2</td>');
|
||||||
|
expect(out).toContain('<td>200</td>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空数组渲染成空容器,不留一行占位', () => {
|
||||||
|
const out = renderView(html, { records: [] });
|
||||||
|
expect(out).toBe('<table></table>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行先展开再填标量槽 —— 顺序反了行内会被外层作用域填掉', () => {
|
||||||
|
const out = renderView('<ul data-rows="rs"><li data-slot="v"></li></ul><b data-slot="v"></b>', {
|
||||||
|
rs: [{ v: '行内' }],
|
||||||
|
v: '外层',
|
||||||
|
});
|
||||||
|
expect(out).toContain('<li>行内</li>');
|
||||||
|
expect(out).toContain('<b>外层</b>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('转义', () => {
|
||||||
|
it('四个字符各归其位', () => {
|
||||||
|
expect(escapeHtml('<a href="x">&</a>')).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('<tbody data-rows="quote_intake.record"><tr><td data-slot="pn"></td></tr></tbody>', scope);
|
||||||
|
expect(out).toContain('<td>LM358DR</td>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,8 @@ export type InteractionService = {
|
|||||||
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
||||||
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
||||||
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
|
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
|
||||||
|
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
|
||||||
|
viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
|
||||||
|
|
||||||
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
||||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export {
|
|||||||
outputControl,
|
outputControl,
|
||||||
templateIsSafe,
|
templateIsSafe,
|
||||||
} from './internal/domain/control-handover.ts';
|
} 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 { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
||||||
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export function createInteractionStore(db: Db): InteractionStore {
|
|||||||
set: { blocks: values.blocks, html: values.html, updatedAt: values.updatedAt },
|
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) {
|
async setPhase(taskId: TaskId, phase: InteractionPhase) {
|
||||||
await db
|
await db
|
||||||
.insert(taskViewTable)
|
.insert(taskViewTable)
|
||||||
|
|||||||
@@ -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, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSlot(scope: unknown, path: string): unknown {
|
||||||
|
/*
|
||||||
|
* 整键优先:作用域里有些键**本身带点号**(台账地址 `域.对象`)。
|
||||||
|
* 先按字面整键找,找不到再按点号下行 —— 反过来会把地址键拆成两级、永远找不到。
|
||||||
|
*/
|
||||||
|
if (typeof scope === 'object' && scope !== null && !Array.isArray(scope)) {
|
||||||
|
const direct = (scope as Record<string, unknown>)[path];
|
||||||
|
if (direct !== undefined) return direct;
|
||||||
|
}
|
||||||
|
return path.split('.').reduce<unknown>((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<string, unknown>)[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<Record<string, unknown>>): 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}</${tag}>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行模版:`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<Record<string, unknown>>): 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<string, unknown>),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
return `${open}${body}</${tag}>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
||||||
|
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||||
|
return renderTemplate(renderRows(html, scope), scope);
|
||||||
|
}
|
||||||
@@ -68,6 +68,10 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
|
|||||||
broadcast({ kind: 'view-html', taskId, html });
|
broadcast({ kind: 'view-html', taskId, html });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async viewOf(taskId) {
|
||||||
|
return deps.store.getView(taskId);
|
||||||
|
},
|
||||||
|
|
||||||
async setPhase(taskId: TaskId, phase: InteractionPhase) {
|
async setPhase(taskId: TaskId, phase: InteractionPhase) {
|
||||||
await deps.store.setPhase(taskId, phase);
|
await deps.store.setPhase(taskId, phase);
|
||||||
const output = outputControl(phase);
|
const output = outputControl(phase);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type InteractionStore = {
|
|||||||
appendMessage(taskId: TaskId, message: ChatMessage): Promise<void>;
|
appendMessage(taskId: TaskId, message: ChatMessage): Promise<void>;
|
||||||
listMessages(taskId: TaskId): Promise<ChatMessage[]>;
|
listMessages(taskId: TaskId): Promise<ChatMessage[]>;
|
||||||
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }): Promise<void>;
|
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }): Promise<void>;
|
||||||
|
getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
|
||||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
||||||
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,3 +59,18 @@ body {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 8px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { TaskDetail, ViewBlock, WorkOrder } from '@contracts';
|
|||||||
import { ACTION_EFFECT_LABEL, WORK_ORDER_MODE_LABEL } from '@contracts';
|
import { ACTION_EFFECT_LABEL, WORK_ORDER_MODE_LABEL } from '@contracts';
|
||||||
import { Ico } from '@web-shared/Ico.tsx';
|
import { Ico } from '@web-shared/Ico.tsx';
|
||||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||||
import { ViewBlocks } from '@ui';
|
import { ViewBlocks, ViewContainer } from '@ui';
|
||||||
|
|
||||||
type Tab = 'todo' | 'result' | 'detail' | 'log';
|
type Tab = 'todo' | 'result' | 'detail' | 'log';
|
||||||
|
|
||||||
@@ -65,7 +65,16 @@ export function ViewRegion({ detail, onReply }: ViewRegionProps) {
|
|||||||
open.map((order) => <WorkOrderCard key={order.id} order={order} onReply={onReply} />)
|
open.map((order) => <WorkOrderCard key={order.id} order={order} onReply={onReply} />)
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{tab === 'result' && <ViewBlocks blocks={detail.view as ViewBlock[]} />}
|
{/*
|
||||||
|
活动推的 HTML 优先:那是它按当下局面组织的**完整一屏**;
|
||||||
|
没推 HTML 时退回结构化块。两者都经 ViewContainer 渲染 —— feature 不碰 innerHTML。
|
||||||
|
*/}
|
||||||
|
{tab === 'result' &&
|
||||||
|
(detail.viewHtml === undefined ? (
|
||||||
|
<ViewBlocks blocks={detail.view as ViewBlock[]} />
|
||||||
|
) : (
|
||||||
|
<ViewContainer html={detail.viewHtml} title={`${detail.card.activity} · 视图`} />
|
||||||
|
))}
|
||||||
|
|
||||||
{tab === 'detail' && (
|
{tab === 'detail' && (
|
||||||
<div className="jc-vr-detail">
|
<div className="jc-vr-detail">
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export const taskDetailSchema = z.object({
|
|||||||
workOrders: z.array(workOrderSchema),
|
workOrders: z.array(workOrderSchema),
|
||||||
chat: z.array(chatMessageSchema),
|
chat: z.array(chatMessageSchema),
|
||||||
view: z.array(viewBlockSchema),
|
view: z.array(viewBlockSchema),
|
||||||
|
/** 活动推送的视图 HTML —— 容器沙箱渲染,无脚本执行权(AM 07) */
|
||||||
|
viewHtml: z.string().optional(),
|
||||||
log: z.array(
|
log: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ const stepBase = {
|
|||||||
.optional(),
|
.optional(),
|
||||||
/** 条件跳步:任务数据上的确定性谓词(S-FEEL),零 LLM */
|
/** 条件跳步:任务数据上的确定性谓词(S-FEEL),零 LLM */
|
||||||
when: z.string().optional(),
|
when: z.string().optional(),
|
||||||
|
/**
|
||||||
|
* 这一步呈现哪张视图模版(`assets.viewTemplates` 里的 name)。
|
||||||
|
* 「flow:按**步序位置**推送声明的模版」(AM 07)——推什么由声明给定,不由临场决定。
|
||||||
|
*/
|
||||||
|
view: z.string().optional(),
|
||||||
failure: stepFailurePolicySchema,
|
failure: stepFailurePolicySchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user