汇报流:活动按步序位置推视图,容器沙箱渲染

「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:
2026-07-27 10:47:20 +08:00
co-authored by Claude Opus 5
parent abd371b51c
commit ac2fecd005
29 changed files with 574 additions and 10 deletions
@@ -165,6 +165,14 @@ export function lastSelectedPorts(position: FlowPosition): Record<string, string
* 悄悄写出一条空记录。
*/
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) => {
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
if (Array.isArray(acc)) {
@@ -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<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`;非回传形状的人事件按原样当出参 */
function replyValuesOf(payload: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>;
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 {
+9
View File
@@ -64,6 +64,15 @@ export type SchemaValidator = {
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) */
export type CardGateway = {
issue(input: {