人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的: 件上就地改 · 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名, 于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 —— 而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。 · **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。 删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)—— 「没回传就是删了」会把一次不完整的回传变成一次删表。 · **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。 · 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。 判据与门 · 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来, 人填好的目标价它照样判「客户未提供」的毛病由此消掉。 · 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。 · 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开, 于是一单没打标就进了放行门。三个数全由基座现数。 · 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。 **指向两扇打不开的门,比不指还糟。** 界面不撒谎 · 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。 · 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、 往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。 · 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。 · 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。 · 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。 A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。 五道门禁全绿(971 passed)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
@@ -18,12 +18,29 @@
|
||||
`MemoryRecorder`(app 注入 memory)、`Audience`(app 注入 org)
|
||||
被依赖: release, loop-engine, flow-engine, task, app(SSE 路由)
|
||||
|
||||
近变(2026-07-30):新增 `onPush(listener)` —— **系统级转接**,装配层接走全部推送转给前端 SSE。
|
||||
从前只有 `subscribe(staffId, …)`,而它一个订阅者都没有:AI 说的每一句、
|
||||
推的每一帧画面都广播给了空气,前端只好靠人点刷新。不复用 `subscribe` 是因为
|
||||
转接是系统的事,为它编一个假员工 id 是撒谎;不按人筛也不漏 —— 推送不带业务载荷,
|
||||
谁该看见由前端自己那几把查询决定。
|
||||
对话流投影另加 `narration` 一类(AI 对人说的话)。
|
||||
|
||||
不变量(OS 4.1 / 09,AM 07):
|
||||
- **输入单入口**:`inputControl()` 恒为 system —— 输入侧不参与转交;
|
||||
视图区不得产生回环输入,浏览性交互由前端容器本地处理
|
||||
- **控制权按态转交**:活动在跑(active / awaiting-*)归活动,其余归系统;
|
||||
转交随引擎事件自动发生,活动不感知交接
|
||||
- **视图区无脚本执行权**:含 `<script>` / `on*` 事件处理器 / 外部资源的模版一律 `TemplateRejectedError`
|
||||
- **填数只做取值与转义**:`data-slot="路径"` 换文本、`data-rows="路径"` 逐行复制;
|
||||
无表达式、无分支、无循环 —— 模版一旦能算,「无脚本执行权」就形同虚设。
|
||||
两个表现档,都把判断留在样式表、不留在填数里:
|
||||
· `data-blank` 让空值**真的留空**(不填「—」),好让模版自己的 CSS
|
||||
去标必填/选填缺口(`td:empty::after` / `tr:has(td.req:empty)`);
|
||||
· `data-class-of="路径"` 把该值消毒成 `v-<值>` 追加进 class(只放行
|
||||
ASCII 字母数字与横杠、封顶 24 字;一个合规字符都没有就不给 class)——
|
||||
「A 绿 / B 蓝 / C 橙」这类按值分档的表现,靠它投影,不靠模版算。
|
||||
**两个档互不依赖**:`data-class-of` 不以 `data-slot` 为前提,
|
||||
纯色块(格里不写字,只按值上色)照样上得了色
|
||||
- **前端零过程状态**:对话流与视图都是服务端投影,刷新即重建;SSE 只推失效与内容,不推状态机
|
||||
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道;
|
||||
AI 发言与系统事件**不另设写入口**,一律由 `projectChat` 从任务记忆筛出 ——
|
||||
|
||||
@@ -16,7 +16,7 @@ const entry = (kind: string, over: Partial<NarrativeEntry> = {}): NarrativeEntry
|
||||
|
||||
describe('projectChat —— 对话流与工作日志同源不同筛选', () => {
|
||||
it('引擎自转的条目一律不进对话区', () => {
|
||||
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'card-reply', 'control'];
|
||||
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'control'];
|
||||
expect(projectChat(noise.map((k) => entry(k)))).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -54,9 +54,174 @@ describe('projectChat —— 对话流与工作日志同源不同筛选', () =>
|
||||
expect(projectChat([entry('card-sent', { detail: { kind: 'chat-card', id: 'c-1' } })])).toEqual([]);
|
||||
});
|
||||
|
||||
it('放行裁决与终态投成阶段结论', () => {
|
||||
const out = projectChat([entry('release-verdict', { id: 'r' }), entry('terminal', { id: 't' })]);
|
||||
/*
|
||||
* **顺利收尾一条都不出**(2026-08-02 用户当场指出:「终态:released 删除」)。
|
||||
*
|
||||
* 从前这一段连出三条:「裁决:pass」「落地收敛,发射输出口:intake」「终态:released」——
|
||||
* 一件事说三遍。2026-08-01 先砍成一条(留终态);同日验收卡办结后换成了「落地结果」
|
||||
* (落了几行、进了哪几张表、转去哪儿了),那一条也就**没话可说了** ——
|
||||
* 在一件已经交代清楚的事后面补一句「结束了」,是废话;何况它摆的还是英文状态名。
|
||||
*/
|
||||
it('**顺利收尾:对话区一条都不出** —— 落地结果那一屏已经说全了', () => {
|
||||
const out = projectChat([
|
||||
entry('release-verdict', { id: 'r', title: '裁决:pass' }),
|
||||
entry('release-verdict', { id: 'r2', title: '落地收敛,发射输出口:intake' }),
|
||||
entry('terminal', { id: 't', title: '终态:released' }),
|
||||
]);
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
|
||||
/*
|
||||
* **没顺利收尾的仍要出声** —— 那时落地结果那一屏根本不会出现,
|
||||
* 而人需要知道这一单停在哪儿了。措辞取共用字典,不摆 `blocked` 这种词。
|
||||
*/
|
||||
it('卡住 / 退回 / 预算耗尽:出一条,且说人话', () => {
|
||||
for (const [state, said] of [
|
||||
['blocked', '这一单受阻待人'],
|
||||
['exhausted', '这一单预算耗尽'],
|
||||
['stalled', '这一单停滞熔断'],
|
||||
] as const) {
|
||||
const out = projectChat([entry('terminal', { id: 't', title: `终态:${state}` })]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe(said);
|
||||
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('conclusion');
|
||||
}
|
||||
});
|
||||
|
||||
it('认不出的状态原样留着 —— 宁可笼统,不可编', () => {
|
||||
const out = projectChat([entry('terminal', { id: 't', title: '终态:什么新状态' })]);
|
||||
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('终态:什么新状态');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
|
||||
*
|
||||
* 交代与卡本是同一轮的两半 —— 结论说「这一轮办完了什么、为什么找你」,
|
||||
* 卡是「你要办的那件事」。各占一个气泡,每轮就是两条:一条话、
|
||||
* 一条只有标题加个按钮的卡,而卡的标题多半是那句话的缩写。
|
||||
*/
|
||||
describe('同一轮的交代与卡并成一条', () => {
|
||||
const say = (id: string, text: string) => entry('narration', { id, title: text });
|
||||
const card = (id: string, title: string, wo: string) =>
|
||||
entry('card-sent', { id, title, detail: { kind: 'work-order', id: wo } });
|
||||
|
||||
it('刚说过话就发卡 → 一个气泡,话是正文、卡挂在上面', () => {
|
||||
const out = projectChat([say('n', '九条料都拆好了,缺两格,请核对确认'), card('c', '受理需求确认', 'wo-1')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('九条料都拆好了,缺两格,请核对确认');
|
||||
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-1');
|
||||
});
|
||||
|
||||
it('并进去之后**标记跟着卡走** —— 验收卡要 criterion,裁决表单才渲得出来', () => {
|
||||
const out = projectChat([say('n', '判据我都裁过了,交你签'), card('c', '收口验收', 'wo-2')]);
|
||||
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('criterion');
|
||||
});
|
||||
|
||||
it('**没说话就自成一条** —— 卡不能不出现', () => {
|
||||
const out = projectChat([card('c', '受理需求确认', 'wo-3')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-3');
|
||||
});
|
||||
|
||||
it('一句话只领一张卡 —— 同拍两张卡的第二张自成一条,不覆盖第一张', () => {
|
||||
const out = projectChat([say('n', '两件事要你办'), card('a', '卡一', 'wo-a'), card('b', '卡二', 'wo-b')]);
|
||||
expect(out.map((m) => (m.k === 'ai' ? m.ref : undefined))).toEqual(['wo-a', 'wo-b']);
|
||||
});
|
||||
|
||||
it('隔着一条系统事件就不并 —— 那是上一轮的话,不是这张卡的结论', () => {
|
||||
const out = projectChat([
|
||||
say('n', '我去问客户'),
|
||||
entry('observation', { id: 'o', title: '客户回信了' }),
|
||||
card('c', '受理需求确认', 'wo-4'),
|
||||
]);
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('收尾那条结论不认领卡 —— 它是这一单的句号,不是派工', () => {
|
||||
// 用**没顺利收尾**的那种:released 起同日不再出声(见上一组)
|
||||
const out = projectChat([entry('terminal', { id: 't', title: '终态:blocked' }), card('c', '卡', 'wo-5')]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.every((m) => m.k === 'ai' && m.tag === 'conclusion')).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **结论口吻的交代照样并卡**。
|
||||
*
|
||||
* 挡住并卡的判断一度写成 `tag !== 'conclusion'` —— 而交代本身常常就是结论口吻
|
||||
* (「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当。
|
||||
* 实测:第一轮并对了、收口那一轮没并 —— 同一屏两种样子。
|
||||
* 不许并卡的只有**收尾那一句**(terminal),按来路认,不按语气签认。
|
||||
*/
|
||||
it('模型把交代标成 conclusion,卡照样并进去', () => {
|
||||
const out = projectChat([
|
||||
entry('narration', { id: 'n', title: '判据我都裁过了,交你签', detail: { tag: 'conclusion' } }),
|
||||
card('c', '收口验收', 'wo-6'),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-6');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **AI 会不会说话** —— 这一类从前没有,后果是对话区从头到尾空白。
|
||||
*
|
||||
* 实测:A1 跑十几拍、写了七行台账、调了两次工具,负责人的对话区一个字也没有 ——
|
||||
* 他只看得见「多了一个任务」,不知道机器干到哪儿、凭什么这么判。
|
||||
* 「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
|
||||
*/
|
||||
describe('narration —— AI 对人说的话进对话区', () => {
|
||||
const entry = (kind: string, title: string, detail?: Record<string, unknown>) => ({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
at: '2026-07-30T01:00:00.000Z',
|
||||
kind,
|
||||
title,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
});
|
||||
|
||||
it('交代进展投成 AI 发言', () => {
|
||||
const out = projectChat([entry('narration', '三条已归一,缺 1 个交期')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ k: 'ai', text: '三条已归一,缺 1 个交期', tag: 'guidance' });
|
||||
});
|
||||
|
||||
it('语气签照活动标的来;标不出按「指导」计', () => {
|
||||
expect(projectChat([entry('narration', 'x', { tag: 'conclusion' })])[0]).toMatchObject({ tag: 'conclusion' });
|
||||
expect(projectChat([entry('narration', 'x', { tag: '我编的签' })])[0]).toMatchObject({ tag: 'guidance' });
|
||||
});
|
||||
|
||||
it('机器踪迹照旧不进对话区 —— 它不是日志的副本', () => {
|
||||
const out = projectChat([
|
||||
entry('action', '写草稿:quote_intake.record 3 行'),
|
||||
entry('engine-transition', 'manualStart:S_intake'),
|
||||
entry('result', 'crm.history-price-read 返回'),
|
||||
entry('thought', '装配技能:intake.read-rfq@2.0.0'),
|
||||
]);
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('回执与内部观察(PRD:页面交互是三类唤醒事件之一)', () => {
|
||||
it('执行工单回传投成人事件回执 —— 人办完一件事,对话流上要有一句人话的痕', () => {
|
||||
// 认的是 `detail.mode`,不是标题前缀:措辞是给人看的、随时会改,改了这条不该失灵
|
||||
const out = projectChat([
|
||||
entry('card-reply', { id: 'r1', title: '补全三条料的品牌', detail: { mode: 'execution' } }),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ k: 'system', type: 'human' });
|
||||
expect((out[0] as { text: string }).text).toBe('「补全三条料的品牌」已办结,AI 接着往下推。');
|
||||
});
|
||||
|
||||
it('对话卡回传不投 —— 卡自己会翻成已答态,再来一条回执是重影', () => {
|
||||
expect(projectChat([entry('card-reply', { id: 'r2', title: '补齐品牌', detail: { k: 'elicitation' } })])).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('引擎内部观察(internal 标)进日志不进对话', () => {
|
||||
expect(projectChat([entry('observation', { id: 'o9', title: 'S1 已在进行中 —— 无需再启动', detail: { internal: true } })])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('裸「事件到达(human):卡号」不投 —— 对人只是一串 UUID,回执已用人话说过', () => {
|
||||
expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,3 +108,173 @@ describe('整键优先 —— 台账地址本身带点号', () => {
|
||||
expect(out).toContain('<td>LM358DR</td>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-blank` —— 「这一格客户没给」与「这一格填了个破折号」是两件事。
|
||||
*
|
||||
* 报告里的必填 / 选填胶囊、缺必填的整行标底,全靠这一格**真的空**才生效
|
||||
* (`td:empty::after` / `tr:has(td.req:empty)`)。填了「—」就永远标不出来。
|
||||
*/
|
||||
describe('data-blank:空值真的留空', () => {
|
||||
it('空串 / null / 缺席一律留空,属性随槽位一并消费', () => {
|
||||
const html = '<td class="req" data-blank data-slot="eau"></td>';
|
||||
expect(renderView(html, { eau: '' })).toBe('<td class="req"></td>');
|
||||
expect(renderView(html, { eau: null })).toBe('<td class="req"></td>');
|
||||
expect(renderView(html, {})).toBe('<td class="req"></td>');
|
||||
});
|
||||
|
||||
it('有值照填 —— 0 与 false 都是值,不算空', () => {
|
||||
const html = '<td class="req" data-blank data-slot="v"></td>';
|
||||
expect(renderView(html, { v: 30_000 })).toBe('<td class="req">30000</td>');
|
||||
expect(renderView(html, { v: 0 })).toBe('<td class="req">0</td>');
|
||||
expect(renderView(html, { v: false })).toBe('<td class="req">否</td>');
|
||||
});
|
||||
|
||||
it('不带 data-blank 的照旧填「—」—— 老模版行为不动', () => {
|
||||
expect(renderView('<b data-slot="x"></b>', { x: '' })).toBe('<b>—</b>');
|
||||
});
|
||||
|
||||
it('逐行展开时每行各判各的空', () => {
|
||||
const out = renderView('<tbody data-rows="rs"><tr><td class="req" data-blank data-slot="eau"></td></tr></tbody>', {
|
||||
rs: [{ eau: 30_000 }, { eau: '' }],
|
||||
});
|
||||
expect(out).toBe('<tbody><tr><td class="req">30000</td></tr><tr><td class="req"></td></tr></tbody>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-class-of` —— **按值上色**,模版仍然不许算。
|
||||
*
|
||||
* 「A 绿 / B 橙 / C 紫」这类分档表现,从前只能三个值一个色 —— 那等于假装读懂了值。
|
||||
* 把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
|
||||
* 属性位是唯一一处让值进属性的地方,所以消毒必须硬。
|
||||
*/
|
||||
describe('data-class-of:按值上色', () => {
|
||||
it('值消毒成 v-<值> 追加进 class,属性随之消费', () => {
|
||||
const html = '<td class="pill" data-class-of="type_tag" data-slot="type_tag"></td>';
|
||||
expect(renderView(html, { type_tag: 'A' })).toBe('<td class="pill v-a">A</td>');
|
||||
expect(renderView(html, { type_tag: 'C' })).toBe('<td class="pill v-c">C</td>');
|
||||
});
|
||||
|
||||
it('没有 class 属性就新建一个', () => {
|
||||
expect(renderView('<i data-class-of="k" data-slot="k"></i>', { k: 'B' })).toBe('<i class="v-b">B</i>');
|
||||
});
|
||||
|
||||
it('布尔与数字都投影得出来 —— 是/否两态也要能分色', () => {
|
||||
expect(renderView('<b data-class-of="f" data-slot="f"></b>', { f: true })).toBe('<b class="v-true">是</b>');
|
||||
expect(renderView('<b data-class-of="n" data-slot="n"></b>', { n: 0 })).toBe('<b class="v-0">0</b>');
|
||||
});
|
||||
|
||||
it('**值只放行 ASCII 字母数字与横杠**:引号、尖括号、空格全剔掉,注不进属性', () => {
|
||||
const out = renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '" onclick=alert(1) <b>' });
|
||||
// class 里只剩白名单字符;文本位照旧转义 —— 两处都注不进去
|
||||
expect(out).toBe('<b class="x v-onclickalert1b">" onclick=alert(1) <b></b>');
|
||||
expect(out).not.toContain('onclick=alert(1)"');
|
||||
});
|
||||
|
||||
it('一个合规字符都没有(中文 / 空值)就不给 class —— 宁可没颜色,不给错钩子', () => {
|
||||
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '老客户' })).toBe('<b class="x">老客户</b>');
|
||||
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', {})).toBe('<b class="x">—</b>');
|
||||
});
|
||||
|
||||
it('逐行展开时每行按自己的值上色', () => {
|
||||
const out = renderView('<tbody data-rows="rs"><tr><td data-class-of="t" data-slot="t"></td></tr></tbody>', {
|
||||
rs: [{ t: 'A' }, { t: 'C' }],
|
||||
});
|
||||
expect(out).toBe('<tbody><tr><td class="v-a">A</td></tr><tr><td class="v-c">C</td></tr></tbody>');
|
||||
});
|
||||
|
||||
/*
|
||||
* **上色不以填字为条件** —— 这一组是补的账。
|
||||
*
|
||||
* 从前只认「带 data-slot 的元素」,于是分布条那种纯色块
|
||||
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
|
||||
* 属性还留在成品上,一个 class 都没长出来。屏上是一排灰格子 ——
|
||||
* 而灰格子看着像「还没算出来」,不像「没上色」,查了两遍才认出来。
|
||||
* 上面每一条老用例都同时写了 data-slot,所以谁都没拦住它。
|
||||
*/
|
||||
it('**只上色不填字**:没有 data-slot 也照样长出 class', () => {
|
||||
expect(renderView('<i data-class-of="t"></i>', { t: 'B' })).toBe('<i class="v-b"></i>');
|
||||
expect(renderView('<i class="seg" data-class-of="t"></i>', { t: 'C' })).toBe('<i class="seg v-c"></i>');
|
||||
});
|
||||
|
||||
it('只上色的格子逐行展开,每格按自己那一行的值', () => {
|
||||
const out = renderView('<div class="bar" data-rows="rs"><i data-class-of="t"></i></div>', {
|
||||
rs: [{ t: 'A' }, { t: 'B' }, { t: 'C' }],
|
||||
});
|
||||
expect(out).toBe('<div class="bar"><i class="v-a"></i><i class="v-b"></i><i class="v-c"></i></div>');
|
||||
});
|
||||
|
||||
it('值取不到就把属性摘掉了事 —— 不留半成品属性到成品上', () => {
|
||||
expect(renderView('<i class="seg" data-class-of="nope"></i>', {})).toBe('<i class="seg"></i>');
|
||||
expect(renderView('<i data-class-of="k"></i>', { k: '老客户' })).toBe('<i></i>');
|
||||
});
|
||||
|
||||
it('壳上的 data-class-of 按外层作用域上色,行内的仍按各自那一行', () => {
|
||||
const out = renderView('<div data-class-of="hit" data-rows="rs"><i data-class-of="t"></i></div>', {
|
||||
hit: 'r1',
|
||||
rs: [{ t: 'A' }, { t: 'C' }],
|
||||
});
|
||||
expect(out).toBe('<div class="v-r1"><i class="v-a"></i><i class="v-c"></i></div>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-edit` + `data-col` —— **就地改**:可改的格子渲两份,只读那份与输入孪生件。
|
||||
*
|
||||
* 为什么两份都渲、而不是渲成输入:同一份件既要在待处理页给人改,
|
||||
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉。
|
||||
* 显示哪一份由样式表按根节点上的 `editing` 决定,填数这一侧仍然只做取值。
|
||||
*/
|
||||
describe('data-edit / data-col:可改格子的输入孪生件', () => {
|
||||
const ROWS = '<tbody data-rows="rs" data-edit="d.o"><tr><td><span class="jc-vcell mono" data-slot="pn" data-col="pn"></span></td></tr></tbody>';
|
||||
|
||||
it('容器标了 data-edit,格子就多一个带值的 input', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: 'AO6604' }] });
|
||||
expect(out).toContain('<span class="jc-vcell mono">AO6604</span>');
|
||||
expect(out).toContain('<input class="jc-cell mono" data-col="pn" data-row="0" value="AO6604"/>');
|
||||
});
|
||||
|
||||
it('没标 data-edit 的表一个 input 都不长 —— 归档件就是件,不是表单', () => {
|
||||
const out = renderView(ROWS.replace(' data-edit="d.o"', ''), { rs: [{ pn: 'AO6604' }] });
|
||||
expect(out).not.toContain('<input');
|
||||
});
|
||||
|
||||
it('行号钉在每一行上 —— 写回时按它对行', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: 'A' }, { pn: 'B' }, { pn: 'C' }] });
|
||||
expect(out.match(/data-row="\d"/g)).toEqual(['data-row="0"', 'data-row="1"', 'data-row="2"']);
|
||||
});
|
||||
|
||||
it('空值的 input 真空着 —— 空的输入框显示自己的 placeholder,那正是「这一格还没有」', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: '' }] });
|
||||
expect(out).toContain('value=""');
|
||||
});
|
||||
|
||||
it('**值进属性也要转义** —— 属性位插值只此一处,引号进去就是逃逸', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: '" onfocus=alert(1) x="' }] });
|
||||
expect(out).toContain('value="" onfocus=alert(1) x=""');
|
||||
expect(out).not.toMatch(/value="" onfocus/);
|
||||
});
|
||||
|
||||
it('排版类跟着走(mono / num),别的类不带过去 —— 同一列两态字体要一致', () => {
|
||||
const num = ROWS.replace('jc-vcell mono', 'jc-vcell num req');
|
||||
expect(renderView(num, { rs: [{ pn: '30000' }] })).toContain('<input class="jc-cell num"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
|
||||
it('第二遍渲染不会再长一个 input(属性已被消费)', () => {
|
||||
const once = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
|
||||
rs: [{ pn: 'X' }],
|
||||
});
|
||||
expect((once.match(/<input/g) ?? []).length).toBe(1);
|
||||
expect(renderView(once, {})).toBe(once);
|
||||
});
|
||||
|
||||
it('模版里仍然没有脚本 —— 输入件是纯 HTML,没有事件处理器', () => {
|
||||
const out = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
|
||||
rs: [{ pn: 'X' }],
|
||||
});
|
||||
expect(out).not.toMatch(/<script|\son[a-z]+=/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,9 +40,16 @@ export type InteractionService = {
|
||||
/** 视图流控:活动按局面推结构化块 */
|
||||
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
||||
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
||||
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
|
||||
/**
|
||||
* 推一帧 HTML。`template` **要一起存下来** —— 件是投影,而投影得能按当下数据**重渲**。
|
||||
*
|
||||
* 只存渲染好的 HTML,那一帧就成了快照:人在件上改完几格,由数据算出来的东西
|
||||
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在渲染那一刻 ——
|
||||
* 屏上一半新一半旧,那不是没更新,是界面在撒谎。记住模版名,才重得出来。
|
||||
*/
|
||||
pushViewHtml(taskId: TaskId, html: string, template?: string): Promise<void>;
|
||||
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
|
||||
viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
|
||||
viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; template?: string } | undefined>;
|
||||
|
||||
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
||||
@@ -50,6 +57,17 @@ export type InteractionService = {
|
||||
|
||||
/** SSE:单连接、按信封目标失效缓存 */
|
||||
subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription;
|
||||
/**
|
||||
* **系统级转接**:装配层把所有推送接出去(转给前端的 SSE 广播),不按人筛。
|
||||
*
|
||||
* 为什么不复用 `subscribe`:那个要一个收件人,而转接是系统的事 ——
|
||||
* 为它编一个假员工 id 是撒谎。不按人筛也不漏:推送**不带业务载荷**
|
||||
* (只说「哪个任务动了」),谁该看见由前端自己那几把查询决定(§9.2 前端零过程状态)。
|
||||
*
|
||||
* 没有这个口子的后果实测过:AI 说的每一句、推的每一帧画面都广播给了空气 ——
|
||||
* `subscribe` 一个订阅者都没有,前端只好靠人点刷新。
|
||||
*/
|
||||
onPush(listener: (envelope: PushEnvelope) => void): Subscription;
|
||||
/** 供上层广播失效信号 */
|
||||
invalidate(target: 'task' | 'workflow' | 'desk', id: string): void;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@ export const taskViewTable = sys.table('task_view', {
|
||||
taskId: text('task_id').primaryKey(),
|
||||
blocks: jsonb('blocks'),
|
||||
html: text('html'),
|
||||
/** 这一帧用的模版名 —— 数据变了要按同一张重渲(件是投影,不是快照) */
|
||||
template: text('template'),
|
||||
phase: text('phase').notNull().default('activating'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -28,11 +28,13 @@ export function createInteractionStore(db: Db): InteractionStore {
|
||||
.orderBy(asc(chatMessageTable.at));
|
||||
return rows.map((row) => chatMessageSchema.parse(row.payload));
|
||||
},
|
||||
async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }) {
|
||||
async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string; template?: string }) {
|
||||
const values = {
|
||||
taskId,
|
||||
blocks: view.blocks === undefined ? null : [...view.blocks],
|
||||
html: view.html ?? null,
|
||||
// 模版名一起存:件是投影 —— 数据变了要按同一张重渲(见迁移 0028 的注释)
|
||||
template: view.template ?? null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
await db
|
||||
@@ -40,7 +42,7 @@ export function createInteractionStore(db: Db): InteractionStore {
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: taskViewTable.taskId,
|
||||
set: { blocks: values.blocks, html: values.html, updatedAt: values.updatedAt },
|
||||
set: { blocks: values.blocks, html: values.html, template: values.template, updatedAt: values.updatedAt },
|
||||
});
|
||||
},
|
||||
async getView(taskId: TaskId) {
|
||||
@@ -49,6 +51,7 @@ export function createInteractionStore(db: Db): InteractionStore {
|
||||
return {
|
||||
...(row.blocks === null ? {} : { blocks: row.blocks as ViewBlock[] }),
|
||||
...(row.html === null ? {} : { html: row.html }),
|
||||
...(row.template === null ? {} : { template: row.template }),
|
||||
};
|
||||
},
|
||||
async setPhase(taskId: TaskId, phase: InteractionPhase) {
|
||||
|
||||
@@ -7,14 +7,26 @@
|
||||
* 否则对话区就成了机器踪迹的副本,失去「非结构化消息」的定位。
|
||||
*
|
||||
* 筛四类:
|
||||
* narration → **AI 发言**:一轮办完时对人说的那一句。
|
||||
* 这一类从前没有,后果是对话区从头到尾空白 —— A1 跑十几拍写了七行台账、
|
||||
* 调了两次工具,而人在对话区一个字看不到,只看得见「多了一个任务」。
|
||||
* 机器踪迹(engine-transition / action / result)照旧不进:
|
||||
* 对话区不是日志的副本,它是**AI 对人说的话**。
|
||||
* observation → 系统事件(灰头像)—— 三类唤醒事件里的外部到达
|
||||
* card-sent → AI 发言 + 指涉工单(卡在对话流上出现,点开即定位到视图区)
|
||||
* release-verdict→ AI 发言 · 阶段结论
|
||||
* terminal → AI 发言 · 阶段结论
|
||||
* card-sent → **并进上一条 AI 发言**,合成「结论 + 功能卡」一个气泡;
|
||||
* 没有可并的就自成一条(卡不能不出现)
|
||||
* terminal → AI 发言 · 阶段结论(收尾只此一条)
|
||||
* human-input 不投:人的话已由 `say` 直接落成消息,再投一遍就是重影。
|
||||
* release-verdict 不投:裁决是人自己刚按下的,发了哪几个口是编排层的事实 —— 都在工作日志里。
|
||||
*
|
||||
* ── 密度(2026-08-01 用户定)─────────────────────────────────────────
|
||||
* **一轮一个气泡**:设定目标 →(看现状 → 定做法 → 动手)× N 轮 → 交代一次,
|
||||
* 可以是仅结论,也可以是结论 + 功能卡两部分。埋头办事的拍不出气泡 ——
|
||||
* 那一段由「AI 工作中」的办事条覆盖(哪些拍算收口见 `turn-runner` 的 `handsOff`)。
|
||||
* 这一层做的是后半段:把同一轮的交代与卡并成一条,并把收尾那三条引擎回声收成一条。
|
||||
*/
|
||||
import type { ChatMessage, EventType } from '@contracts';
|
||||
import { cardIdSchema, workOrderIdSchema } from '@contracts';
|
||||
import type { ChatMessage, EventType, TerminalState } from '@contracts';
|
||||
import { cardIdSchema, TERMINAL_STATE_LABEL, workOrderIdSchema } from '@contracts';
|
||||
|
||||
/** 记忆条目的只读切面 —— 投影只要这几样,不牵入 memory 的完整类型 */
|
||||
export type NarrativeEntry = {
|
||||
@@ -40,31 +52,130 @@ function workOrderRef(detail: Record<string, unknown> | undefined): string | und
|
||||
return detail?.['kind'] === 'work-order' && typeof detail['id'] === 'string' ? detail['id'] : undefined;
|
||||
}
|
||||
|
||||
/** 发言的语气签:活动自己标,标不出按「进展」计 —— 三种签在前端各有其样式 */
|
||||
const TAGS: readonly string[] = ['guidance', 'criterion', 'conclusion'];
|
||||
function tagOf(detail: Record<string, unknown> | undefined): 'guidance' | 'criterion' | 'conclusion' {
|
||||
const tag = detail?.['tag'];
|
||||
return typeof tag === 'string' && TAGS.includes(tag) ? (tag as 'guidance' | 'criterion' | 'conclusion') : 'guidance';
|
||||
}
|
||||
|
||||
export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
|
||||
const out: ChatMessage[] = [];
|
||||
/*
|
||||
* 上一条是不是**收尾那句**(terminal)—— 只有它不许认领后面的卡。
|
||||
*
|
||||
* 不能拿 `tag === 'conclusion'` 当这个判断:交代本身常常就是结论口吻
|
||||
* (「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当。
|
||||
* 实测栽过一次:收口那一轮的交代标了 conclusion,于是并卡被这条挡下,
|
||||
* 验收卡又自成一条 —— 第一轮并对了、第二轮没并,同一屏两种样子。
|
||||
*/
|
||||
let lastIsTerminal = false;
|
||||
for (const e of entries) {
|
||||
const id = cardIdSchema.parse(e.id);
|
||||
if (e.kind === 'observation') {
|
||||
out.push({ k: 'system', id, at: e.at, type: eventTypeOf(e.detail), text: e.title });
|
||||
if (e.kind === 'narration') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: tagOf(e.detail) });
|
||||
lastIsTerminal = false;
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* 触发事件与系统观察在对话区**长一个样**(都是灰头像的系统气泡)——
|
||||
* 分家分在归侧与日志上,不在对话区:人在对话流里要的是「发生了什么」,
|
||||
* 不是「这条属于哪一类」。
|
||||
*/
|
||||
if (e.kind === 'trigger' || e.kind === 'observation') {
|
||||
// 引擎内部观察(已在进行中 / 批截断…)进日志不进对话 —— 对话区不是机器踪迹的副本
|
||||
if (e.detail?.['internal'] === true) continue;
|
||||
/*
|
||||
* 裸「事件到达(human):卡号」也不进:对人它只是一串 UUID,而这件事
|
||||
* 已由**回执**(card-reply 投影)用人话说过一遍 —— 同一件事不说两遍。
|
||||
* 认的是 `detail.kind`(结构化标记),不是标题前缀:措辞改一次,
|
||||
* 按前缀认的过滤就静默失灵,而失灵的样子是「对话区多出一串 UUID」。
|
||||
*/
|
||||
if (e.detail?.['kind'] === 'human') continue;
|
||||
out.push({
|
||||
k: 'system',
|
||||
id,
|
||||
at: e.at,
|
||||
type: eventTypeOf(e.detail),
|
||||
text: e.title,
|
||||
// 起单那句开场白带「查看邮件」—— 其余系统事件没有去处,不加这个键
|
||||
...(e.detail?.['openComms'] === true ? { openComms: true } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* 认的是 `detail.mode`,**不是标题前缀**(2026-07-31 改)——
|
||||
* 从前判的是 `title.startsWith('执行工单回传:')`,而标题措辞是给人看的、随时会改;
|
||||
* 改一次这条投影就静默失灵:人办结了工单,对话区一句话也不出,像没发生过。
|
||||
* 结构化标记改不动,措辞怎么改都不影响。
|
||||
*/
|
||||
if (e.kind === 'card-reply' && e.detail?.['mode'] === 'execution') {
|
||||
/*
|
||||
* 页面交互的**回执**(PRD:三类唤醒事件之一)——人在视图区办结了一件工单,
|
||||
* 对话流上要有一句人话的痕:「工单已办结,AI 接着往下推」。
|
||||
* 没有它,人办完一件事回到对话区,看到的还是办之前那句话 —— 像没发生过。
|
||||
*/
|
||||
out.push({
|
||||
k: 'system',
|
||||
id,
|
||||
at: e.at,
|
||||
type: 'human',
|
||||
text: `「${e.title}」已办结,AI 接着往下推。`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'card-sent') {
|
||||
const ref = workOrderRef(e.detail);
|
||||
// 跨人卡进对方的消息要素,不在本任务对话流上露面
|
||||
if (ref === undefined) continue;
|
||||
out.push({
|
||||
k: 'ai',
|
||||
id,
|
||||
at: e.at,
|
||||
text: e.title,
|
||||
// 验收件请人裁决 = 判据;执行件派工 = 指导
|
||||
tag: e.title.includes('验收') ? 'criterion' : 'guidance',
|
||||
ref: workOrderIdSchema.parse(ref),
|
||||
});
|
||||
// 验收件请人裁决 = 判据;执行件派工 = 指导
|
||||
const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const);
|
||||
/*
|
||||
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
|
||||
*
|
||||
* 交代与卡本是同一轮的两半 —— 结论说「这一轮办完了什么、为什么找你」,
|
||||
* 卡是「你要办的那件事」。从前各占一个气泡,于是每轮都是两条:
|
||||
* 一条话、一条只有标题加个按钮的卡,而卡的标题多半是那句话的缩写。
|
||||
*
|
||||
* 这一拍刚说过话(紧邻的上一条就是它,turn-runner 保证交代先于行动),
|
||||
* 就把卡挂到那句话上,不另起一条。没说话就自己成一条 —— 卡不能不出现。
|
||||
*/
|
||||
const last = out[out.length - 1];
|
||||
if (last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) {
|
||||
out[out.length - 1] = { ...last, tag, ref: workOrderIdSchema.parse(ref) };
|
||||
continue;
|
||||
}
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag, ref: workOrderIdSchema.parse(ref) });
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'release-verdict' || e.kind === 'terminal') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: 'conclusion' });
|
||||
/*
|
||||
* **收尾只出一条**(2026-08-01)。
|
||||
*
|
||||
* 从前这一段连出三条:「裁决:pass」「落地收敛,发射输出口:intake、to-alternative」
|
||||
* 「终态:released」—— 一件事说三遍,而且前两条是引擎内部话:
|
||||
* 裁决是**人自己刚按下的**,不必读回给他听;发了哪几个口是编排层的事实,
|
||||
* 它在工作日志里(那一屏本来就是给排障看的),不该占对话区一条。
|
||||
* 留下的是终态那一条:这一单结没结、怎么结的 —— 那才是他要知道的。
|
||||
*/
|
||||
if (e.kind === 'terminal') {
|
||||
/*
|
||||
* **顺利收尾的那一条不再出声**(2026-08-02 用户当场指出:「终态:released 删除」)。
|
||||
*
|
||||
* 两个毛病:①摆的是 `终态:released` —— 系统内的英文状态名,界面不摆这种词;
|
||||
* ②**它已经没有话可说了** —— 同日起,人签完字之后验收卡那一屏换成了「落地结果」
|
||||
* (落了几行、进了哪几张表、转去哪儿了),该说的它全说了,而且说得具体得多。
|
||||
* 再补一条「结束了」,是在一件已经交代清楚的事后面加一句废话。
|
||||
*
|
||||
* **没顺利收尾的仍要出声**:卡住了、被退回、预算耗尽 —— 那时人需要知道
|
||||
* 「这一单停在哪儿了」,而落地结果那一屏根本不会出现。措辞取共用的
|
||||
* `TERMINAL_STATE_LABEL`(与任务卡、日志同一本字典),不在这儿另抄一份。
|
||||
*/
|
||||
const state = /(?:终态[::]\s*)?([a-z]+)\s*$/.exec(e.title)?.[1];
|
||||
const label = state === undefined ? undefined : TERMINAL_STATE_LABEL[state as TerminalState];
|
||||
if (state !== 'released') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: label === undefined ? e.title : `这一单${label}`, tag: 'conclusion' });
|
||||
lastIsTerminal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -48,6 +48,27 @@ export function displayValue(value: unknown): string {
|
||||
}
|
||||
|
||||
const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
|
||||
/** `data-blank`:这一格空着的时候**真的留空**,不填「—」 */
|
||||
const BLANK = /\s*\bdata-blank(?:\s*=\s*"[^"]*")?/g;
|
||||
/** `data-class-of="路径"`:把该路径的值消毒成一个 class 后缀 —— **按值上色**用 */
|
||||
const CLASS_OF = /\s*\bdata-class-of\s*=\s*"([^"]+)"/;
|
||||
|
||||
/**
|
||||
* 值 → class 后缀。**只留 ASCII 字母数字与横杠,全部小写,封顶 24 字**;
|
||||
* 一个字符都不合规就不给 class(宁可没有颜色,不给一个错的钩子)。
|
||||
*
|
||||
* 为什么要有它:模版不许算,于是「A 绿 / B 橙 / C 紫」这类**按值分档的表现**
|
||||
* 从前一概做不了 —— 只能三个值一个色,那等于假装读懂了值。
|
||||
* 而把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
|
||||
* 消毒是硬的:属性位不开放插值,这里是唯一一处值进属性,所以只放行白名单字符。
|
||||
*/
|
||||
export function classToken(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
const text = String(typeof value === 'boolean' ? (value ? 'true' : 'false') : value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
return text.slice(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把作用域填进模版的槽位。
|
||||
@@ -55,13 +76,72 @@ const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s
|
||||
* 填充**消费槽位**:填完就把 `data-slot` 摘掉 —— 一来第二遍不会再拿外层作用域
|
||||
* 把行内已填好的值冲掉,二来交出去的是干净 HTML,不是带模板属性的半成品。
|
||||
* 未命中的槽位填「—」而不是留原样:模版里的占位文字漏到成品上比空着更糟。
|
||||
*
|
||||
* ── 例外:`data-blank` ────────────────────────────────────────────────
|
||||
* 表格里「这一格客户没给」和「这一格填了个破折号」是两件事。带 `data-blank`
|
||||
* 的槽位空值就**留空**,交给模版自己的 CSS 去标(`td:empty::after` 出「必填 / 选填」
|
||||
* 胶囊、`tr:has(td.req:empty)` 整行标底)—— 判断仍在样式里,不在这里:
|
||||
* 填数还是只做取值与转义两件事,没有表达式、没有分支。
|
||||
*/
|
||||
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)));
|
||||
const filled = html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
|
||||
const raw = `${before}${after}`;
|
||||
const blank = /\bdata-blank\b/.test(raw);
|
||||
const attrs = paintClass(raw.replace(BLANK, ''), scope).replace(/\s+/g, ' ').trimEnd();
|
||||
const value = resolveSlot(scope, path);
|
||||
const empty = value === undefined || value === null || value === '';
|
||||
const text = blank && empty ? '' : escapeHtml(displayValue(value));
|
||||
return `<${tag}${attrs === '' ? '' : ` ${attrs.trim()}`}>${text}</${tag}>`;
|
||||
});
|
||||
/*
|
||||
* **值进 value 属性的那一种** —— `<input … data-slot="pn" data-value/>`。
|
||||
*
|
||||
* 上一遍认的是「开合标签之间填字」,而 input 是空元素,没有「之间」。
|
||||
* 这一份是可改格子的输入孪生件(见 `twinInputs`):值得进 `value=`,
|
||||
* 空值就真空着 —— 空的 input 会显示自己的 placeholder,那正是「这一格还没有」。
|
||||
* 属性位插值只此一处,所以转义照文本位那四个字符来,引号也在其中。
|
||||
*/
|
||||
const withValues = filled.replace(
|
||||
/<input\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*?)\bdata-value\b([^>]*)>/g,
|
||||
(_all, before: string, path: string, mid: string, after: string) => {
|
||||
const value = resolveSlot(scope, path);
|
||||
const empty = value === undefined || value === null || value === '';
|
||||
// 末尾那个自闭合斜杠会被 `after` 一并捞进来 —— 不摘掉就写出 `/ value="…"`
|
||||
const attrs = `${before}${mid}${after}`.replace(/\/\s*$/, '').replace(/\s+/g, ' ').trim();
|
||||
return `<input ${attrs} value="${empty ? '' : escapeHtml(displayValue(value))}"/>`;
|
||||
},
|
||||
);
|
||||
/*
|
||||
* **只上色、不填字的那些格子** —— 剩下的 `data-class-of` 单独走一遍。
|
||||
*
|
||||
* 上一遍只认「带 data-slot 的元素」,于是分布条那种**纯色块**
|
||||
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
|
||||
* 属性还在,一个 class 都没长出来,屏上是一排灰格子 —— 而灰格子看着像
|
||||
* 「还没算出来」,不像「没上色」。两遍都栽过同一个坑,所以把两件事分开:
|
||||
* 取值填字是一件事,按值上色是另一件事,后者不该以前者为条件。
|
||||
*/
|
||||
return withValues.replace(
|
||||
/<([a-zA-Z][\w-]*)\b([^>]*?\bdata-class-of\s*=\s*"[^"]+"[^>]*)>/g,
|
||||
(_all, tag: string, attrs: string) => {
|
||||
const painted = paintClass(attrs, scope).replace(/\s+/g, ' ').trimEnd();
|
||||
return `<${tag}${painted === '' ? '' : ` ${painted.trim()}`}>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-class-of="路径"` → 把该路径的值消毒后追加成 `v-<值>`,并**消费掉属性本身**。
|
||||
* 消费是要紧的:交出去的是干净 HTML,行模版复制第二遍时也不会再追加一次。
|
||||
*/
|
||||
function paintClass(attrs: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
const classOf = CLASS_OF.exec(attrs);
|
||||
if (classOf === null) return attrs;
|
||||
const rest = attrs.replace(CLASS_OF, '');
|
||||
const token = classToken(resolveSlot(scope, classOf[1] ?? ''));
|
||||
if (token === '') return rest;
|
||||
return /\bclass\s*=\s*"[^"]*"/.test(rest)
|
||||
? rest.replace(/\bclass\s*=\s*"([^"]*)"/, (_m, cls: string) => `class="${cls} v-${token}"`)
|
||||
: `${rest} class="v-${token}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,19 +152,57 @@ const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s
|
||||
|
||||
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 raw = `${before}${after}`;
|
||||
const attrs = raw.replace(/\s+/g, ' ').trim();
|
||||
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
|
||||
const value = resolveSlot(scope, path);
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
/*
|
||||
* 这一组行**可就地改**吗 —— 容器上的 `data-edit="域.对象"` 说了算。
|
||||
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
|
||||
*/
|
||||
const editable = /\bdata-edit\s*=\s*"([^"]+)"/.exec(raw) !== null;
|
||||
const body = rows
|
||||
.map((row) =>
|
||||
renderTemplate(inner, (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>),
|
||||
)
|
||||
.map((row, index) => {
|
||||
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
|
||||
return renderTemplate(editable ? twinInputs(inner, index) : inner, one);
|
||||
})
|
||||
.join('');
|
||||
return `${open}${body}</${tag}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */
|
||||
const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g;
|
||||
|
||||
/**
|
||||
* **给可改的格子配一个输入孪生件** —— 只读那一份原样留着,旁边多一个 `<input>`,
|
||||
* 两者由样式表二选一显示(根节点带不带 `editing`)。
|
||||
*
|
||||
* 为什么是「都渲出来」而不是「渲成输入」:同一份件既要在待处理页给人改,
|
||||
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉;
|
||||
* 而容器是 `sandbox="allow-same-origin"` 的无脚本 iframe,外层**读得到也改得动**
|
||||
* 里面的 DOM:开编辑就是给根节点加个 class,收值就是把 input 读出来。
|
||||
* 模版这一侧仍然只做取值 —— 判断(显示哪一份)留在样式表。
|
||||
*
|
||||
* `data-col` 写的是**台账列名**,不是表头文字:写回时基座按列名对格,
|
||||
* 表头改个说法不该让写回错位。
|
||||
*/
|
||||
function twinInputs(inner: string, rowIndex: number): string {
|
||||
return inner.replace(EDITABLE_CELL, (all, _tag: string, attrs: string, col: string) => {
|
||||
const slot = /\bdata-slot\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
|
||||
if (slot === undefined) return all;
|
||||
// 只读那一份的排版类(mono / num)照搬给输入件 —— 同一列两态字体要一致
|
||||
const style = (/\bclass\s*=\s*"([^"]*)"/.exec(attrs)?.[1] ?? '')
|
||||
.split(/\s+/)
|
||||
.filter((c) => c === 'mono' || c === 'num')
|
||||
.join(' ');
|
||||
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}" data-value/>`;
|
||||
// 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音
|
||||
return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
||||
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return renderTemplate(renderRows(html, scope), scope);
|
||||
|
||||
@@ -19,7 +19,8 @@ export type InteractionServiceDeps = {
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
type Subscriber = { staffId: StaffId; onPush: (envelope: PushEnvelope) => void };
|
||||
/** 订阅者:`staffId` 缺省 = 系统级转接(装配层接走全部推送,转给前端 SSE) */
|
||||
type Subscriber = { staffId: StaffId | undefined; onPush: (envelope: PushEnvelope) => void };
|
||||
|
||||
export function createInteractionService(deps: InteractionServiceDeps): InteractionService {
|
||||
// SSE 订阅者表是**连接级**状态,不是过程状态:进程内持有,刷新即从投影重建(§9.2)
|
||||
@@ -70,10 +71,11 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
|
||||
},
|
||||
|
||||
/** 视图模版过净化门:含脚本 / 事件处理器 / 外部资源一律拒推 */
|
||||
async pushViewHtml(taskId, html) {
|
||||
async pushViewHtml(taskId, html, template) {
|
||||
const violations = checkTemplate(html);
|
||||
if (violations.length > 0) throw new TemplateRejectedError(violations);
|
||||
await deps.store.putView(taskId, { html });
|
||||
// 模版名一起存:件是投影,数据变了要能按同一张模版重渲(见 contract 的注释)
|
||||
await deps.store.putView(taskId, { html, ...(template === undefined ? {} : { template }) });
|
||||
broadcast({ kind: 'view-html', taskId, html });
|
||||
},
|
||||
|
||||
@@ -98,6 +100,13 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
|
||||
return { close: () => void subscribers.delete(subscriber) } satisfies Subscription;
|
||||
},
|
||||
|
||||
/** 系统级转接 —— 装配层接走全部推送,转给前端 SSE(见 contract 的注释) */
|
||||
onPush(listener) {
|
||||
const bridge: Subscriber = { staffId: undefined, onPush: listener };
|
||||
subscribers.add(bridge);
|
||||
return { close: () => void subscribers.delete(bridge) } satisfies Subscription;
|
||||
},
|
||||
|
||||
invalidate(target, id) {
|
||||
broadcast({ kind: 'invalidate', target, id });
|
||||
},
|
||||
|
||||
@@ -11,8 +11,8 @@ export type IdGenerator = { next(): string };
|
||||
export type InteractionStore = {
|
||||
appendMessage(taskId: TaskId, message: ChatMessage): Promise<void>;
|
||||
listMessages(taskId: TaskId): Promise<ChatMessage[]>;
|
||||
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }): Promise<void>;
|
||||
getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
|
||||
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string; template?: string }): Promise<void>;
|
||||
getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; template?: string } | undefined>;
|
||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
||||
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user