Archived
工作日志:一行摆首句,展开就把话说完
从前一条只有一行标题,而人真想回看的东西全躺在留痕里没露过面 —— 凭什么算这一步办完了、那一笔落下哪几行、被拒到底拒在哪儿、发的是哪种卡送到哪儿。 现在每条可展开,细节由 detailOf 摘成**一句句人话**(不摆 execution 这类内部名); 摘不出就是空数组,前端据此不给展开钮 —— 点开是空抽屉比不给还糟。 不摆二级条目(用户定):细节不是「键:值」两栏 —— 那是把留痕的字段名搬到人面前, 人还得自己在脑子里连成句子。每句自带主语与语境,单拎出来也读得通。 连带修一处文档与实现不符:「一轮一句」的口径说埋头办事那几拍的交代「留进工作日志」, 而实现把 internal 整类滤掉了,那几句哪儿都看不到。现在带交代的放行, 章写「交代进展」、记在 AI 名下;排障用的内部痕照旧不摆。 两种交代统一「首句上行、全文进抽屉」,一句话说完的不重复摆;截了才加省略号。 契约:任务详情的日志行新增可选 detail(字符串数组),api-report 同批更新。 门禁:typecheck / lint / depcruise / test(1378)全绿;浏览器实测折叠行高 34-36px 齐平、 展开无键值两栏残留。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -768,6 +768,21 @@
|
|||||||
「结构化出参只吐 schema 里声明过的键」(ai/ledger 当年就是这么丢的)。
|
「结构化出参只吐 schema 里声明过的键」(ai/ledger 当年就是这么丢的)。
|
||||||
`quote-intake@49.5.0` 已装;1366 测、typecheck/lint/depcruise 绿。
|
`quote-intake@49.5.0` 已装;1366 测、typecheck/lint/depcruise 绿。
|
||||||
用户在途那单已见到全指导书;e2e 复跑等测试台空出来再补(他的单还开着,不顶)。
|
用户在途那单已见到全指导书;e2e 复跑等测试台空出来再补(他的单还开着,不顶)。
|
||||||
|
- **工作日志:一行摆首句,展开才看细节**(2026-08-03 用户:「细节展开不够好」):
|
||||||
|
从前一条只有一行标题,而留痕里那些人真想回看的东西 —— 凭什么算这一步办完了
|
||||||
|
(`rationale`)、那一笔写了哪几行(主键名单)、被拒到底拒在哪儿(`reason`+`hint`)、
|
||||||
|
发的是哪种卡送到哪儿 —— **一个字都没露过面**。现在 `detailOf` 把它们摘成
|
||||||
|
**人读键值对**(与 title 同一条纪律:不摆内部英文名、认不出的不摆),
|
||||||
|
折叠不占地方、展开才现身;**摘不出就是空数组,前端据此不给展开钮** ——
|
||||||
|
点开是空的抽屉比不给还糟。
|
||||||
|
连带修一处**文档与实现不符**:「一轮一句」口径说埋头办事那几拍的交代
|
||||||
|
「留进工作日志」,而实现把 `internal` 整类滤掉了 —— 那几句**哪儿都看不到**。
|
||||||
|
现在带 `say` 的放行(它是人话),章写「交代进展」、记在 AI 名下;
|
||||||
|
排障用的内部痕(重问原文)照旧不摆。
|
||||||
|
两种交代统一「首句上行、全文进抽屉」(一句话说完的不重复摆,免得点开发现一模一样);
|
||||||
|
截了才加省略号 —— 加了等于说「后面还有」。
|
||||||
|
实测:一单 8 行、6 行有抽屉、行高 34-36px 齐平,展开那两行 141/103px。
|
||||||
|
14 条新测(detailOf 五类 + 压下交代放行 + 首句去重)。
|
||||||
|
|
||||||
## 文档权威地图
|
## 文档权威地图
|
||||||
|
|
||||||
|
|||||||
@@ -1528,6 +1528,7 @@ export declare const taskDetailSchema: z.ZodObject<{
|
|||||||
action: z.ZodString;
|
action: z.ZodString;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
summary: z.ZodOptional<z.ZodString>;
|
summary: z.ZodOptional<z.ZodString>;
|
||||||
|
detail: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type TaskDetail = z.infer<typeof taskDetailSchema>;
|
export type TaskDetail = z.infer<typeof taskDetailSchema>;
|
||||||
@@ -2828,6 +2829,7 @@ export declare const taskDetailRoute: {
|
|||||||
action: z.ZodString;
|
action: z.ZodString;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
summary: z.ZodOptional<z.ZodString>;
|
summary: z.ZodOptional<z.ZodString>;
|
||||||
|
detail: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export type DisplayLogLine = {
|
|||||||
readonly action: string;
|
readonly action: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly summary?: string;
|
readonly summary?: string;
|
||||||
|
/** 展开才看的细节:一段一句人话(摘不出就是空数组 —— 前端据此不给展开钮) */
|
||||||
|
readonly detail: readonly string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +60,7 @@ export async function displayLog(lines: readonly LogLine[], org: OrgService): Pr
|
|||||||
sideLabel: l.sideLabel,
|
sideLabel: l.sideLabel,
|
||||||
who: l.who,
|
who: l.who,
|
||||||
action: l.action,
|
action: l.action,
|
||||||
|
detail: l.detail,
|
||||||
title: `${actor}:${l.title}`,
|
title: `${actor}:${l.title}`,
|
||||||
...(l.summary === undefined ? {} : { summary: l.summary }),
|
...(l.summary === undefined ? {} : { summary: l.summary }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
windowOf,
|
windowOf,
|
||||||
} from '../internal/domain/task-memory.ts';
|
} from '../internal/domain/task-memory.ts';
|
||||||
import type { EntryKind, MemoryEntry } from '../internal/domain/task-memory.ts';
|
import type { EntryKind, MemoryEntry } from '../internal/domain/task-memory.ts';
|
||||||
import { computeMetrics, splitBySide, structuredLog } from '../internal/domain/task-log.ts';
|
import { computeMetrics, detailOf, firstSentence, splitBySide, structuredLog } from '../internal/domain/task-log.ts';
|
||||||
import { assertThreePart, isBinding, NoEvidenceError, selectForLoading } from '../internal/domain/activity-memory.ts';
|
import { assertThreePart, isBinding, NoEvidenceError, selectForLoading } from '../internal/domain/activity-memory.ts';
|
||||||
import type { ActivityMemoryEntry } from '../internal/domain/activity-memory.ts';
|
import type { ActivityMemoryEntry } from '../internal/domain/activity-memory.ts';
|
||||||
|
|
||||||
@@ -540,3 +540,105 @@ describe('放行那一刻的日志尾巴', () => {
|
|||||||
expect(line?.title).toBe('你改的那一笔已落账:目标价 → 0.31(落 quote_intake.record)');
|
expect(line?.title).toBe('你改的那一笔已落账:目标价 → 0.31(落 quote_intake.record)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **展开才看的细节:一段一句人话**(2026-08-03 用户两次定调 ——
|
||||||
|
* 先「细节展开不够好」,再「不要写二级条目,直接正文描述完」)。
|
||||||
|
*
|
||||||
|
* 出的不是字段名而是句子:每句自带主语与语境,单拎出来也读得通。
|
||||||
|
* 摘的纪律:人读、不摆内部英文名、摘不出就空。
|
||||||
|
*/
|
||||||
|
describe('日志细节:说成人话,摘不出就空', () => {
|
||||||
|
it('收步那一条说清「凭什么算办完」—— 人回看时要核的就是它', () => {
|
||||||
|
const d = detailOf(entry('engine-transition', { title: '完成:归一受理', detail: { rationale: '九条已整份落表' } }));
|
||||||
|
expect(d).toEqual(['算办完的依据:九条已整份落表']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('被拒那一条说清缘由与下一步 —— 拒回的话本来就是写给下一拍的', () => {
|
||||||
|
const d = detailOf(entry('result', { detail: { reason: '这一步已办完', hint: '挂到当下这一步' } }));
|
||||||
|
expect(d).toEqual(['没通过的缘由:这一步已办完', '接下来该:挂到当下这一步']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('台账那一条说清落下哪几行 —— 「写了什么」是回看的第一问', () => {
|
||||||
|
const d = detailOf(
|
||||||
|
entry('ledger-write', { detail: { rows: 2, keys: [{ rfq_ref: 'R1', pn: 'A' }, { rfq_ref: 'R1', pn: 'B' }] } }),
|
||||||
|
);
|
||||||
|
expect(d).toEqual(['落下这几行:R1 / A、R1 / B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**卡的种类与去处并成一句**,且摆人读名不摆 execution 这类内部词', () => {
|
||||||
|
const d = detailOf(entry('card-sent', { detail: { mode: 'execution', arrival: 'task-chat' } }));
|
||||||
|
expect(d).toEqual(['发的是执行工单,送到这一单的对话区。']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('摘不出东西 → 空数组(前端据此不给展开钮,不摆空抽屉)', () => {
|
||||||
|
expect(detailOf(entry('terminal', { detail: {} }))).toEqual([]);
|
||||||
|
expect(detailOf(entry('trigger'))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **被压下的每拍交代要留在工作日志里** —— 2026-08-01 立的口径
|
||||||
|
* (对话区一轮一句,埋头办事那几拍的交代「留进工作日志」),
|
||||||
|
* 而实现一直把 `internal` 整类滤掉:那几句哪儿都看不到。
|
||||||
|
*/
|
||||||
|
describe('压下的交代不该消失', () => {
|
||||||
|
const suppressed = entry('observation', {
|
||||||
|
seq: 9,
|
||||||
|
title: '这一拍在往下办事,交代留日志、不进对话区',
|
||||||
|
detail: { internal: true, say: '三颗料都归一好了,两颗要替代。' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('带交代的内部痕进日志,标题摆交代的首句(不是那句机器话)', () => {
|
||||||
|
const line = structuredLog([suppressed])[0];
|
||||||
|
expect(line?.title).toBe('三颗料都归一好了,两颗要替代。');
|
||||||
|
expect(line?.action).toBe('交代进展');
|
||||||
|
// 是 AI 说的话,就记在 AI 名下 —— 从前一律署「系统」
|
||||||
|
expect(line?.who).toBe('agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其余内部痕(排障用的原文留档)照旧不摆', () => {
|
||||||
|
expect(structuredLog([entry('observation', { detail: { internal: true, raw: '{坏 JSON}' } })])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题只摆首句、全文归抽屉 —— 这一屏的用法是**扫**,一行摆全文会把它撑成散文集。
|
||||||
|
* 而一句话说完的交代**不摆全文**:抽屉里跟标题一模一样,人点开会觉得被耍了。
|
||||||
|
*/
|
||||||
|
describe('交代:首句上行,全文进抽屉', () => {
|
||||||
|
const say = (text: string) => entry('observation', { detail: { internal: true, say: text } });
|
||||||
|
|
||||||
|
it('多句交代:标题截到首句(带省略号说明后面还有),抽屉摆全文', () => {
|
||||||
|
const long = '九条料都拆好了。只有 DAG 那颗缺年用量,已在卡上点名。请你审定。';
|
||||||
|
expect(structuredLog([say(long)])[0]?.title).toBe('九条料都拆好了。…');
|
||||||
|
expect(detailOf(say(long))[0]).toBe(long);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('**一句话说完的不摆全文** —— 抽屉与标题一样就是白点一次', () => {
|
||||||
|
const one = '客户已认出,直接往下走。';
|
||||||
|
expect(structuredLog([say(one)])[0]?.title).toBe(one);
|
||||||
|
expect(detailOf(say(one))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没截断就不加省略号 —— 加了等于说「后面还有」,而后面没有', () => {
|
||||||
|
expect(firstSentence('客户已认出。')).toBe('客户已认出。');
|
||||||
|
// 没有断句标点的长句:按字数截,截了才加省略号
|
||||||
|
expect(firstSentence('这是一段没有任何断句标点的交代'.repeat(5)).endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 进了对话区的那种交代也一样:首句上行、全文进抽屉(两种交代在这一屏是同一类东西) */
|
||||||
|
describe('进对话区的交代也只摆首句', () => {
|
||||||
|
const long = entry('narration', { title: '九条料都拆好了。只有 DAG 那颗缺年用量,已在卡上点名。' });
|
||||||
|
|
||||||
|
it('标题截首句,抽屉摆全文与去处', () => {
|
||||||
|
expect(structuredLog([long])[0]?.title).toBe('九条料都拆好了。…');
|
||||||
|
expect(detailOf(long)).toEqual([long.title, '这句已进对话区。']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('一句话的只摆去处,不重复摆全文', () => {
|
||||||
|
const one = entry('narration', { title: '客户已认出,直接往下走。' });
|
||||||
|
expect(detailOf(one)).toEqual(['这句已进对话区。']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ export type LogLine = {
|
|||||||
/** 内容本身,**不含主语** —— 主语由上层拼在前头(名字要查库才知道) */
|
/** 内容本身,**不含主语** —— 主语由上层拼在前头(名字要查库才知道) */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly summary: string | undefined;
|
readonly summary: string | undefined;
|
||||||
|
/**
|
||||||
|
* **展开才看的细节 —— 一段话说完,不摆二级条目**(2026-08-03 用户两次定调:
|
||||||
|
* 先是「细节展开不够好」,再是「不要写二级条目,直接正文描述完」)。
|
||||||
|
*
|
||||||
|
* 从前这一屏一条只有一行标题:凭什么算这一步办完了、那一笔写了哪几行、
|
||||||
|
* 行动被拒到底拒在哪儿 —— 全躺在留痕的 `detail` 里,一个字都没露面。
|
||||||
|
* 中间试过摘成「键:值」两栏,那是**把留痕的字段名搬到人面前**,
|
||||||
|
* 人还得自己在脑子里把它们连成句子。
|
||||||
|
*
|
||||||
|
* 所以这里出的是**一句句人话**:每条自带主语与语境,单独拎出来也读得通
|
||||||
|
* (与 `title` 同一条纪律:不摆系统内的英文名、认不出的宁可不摆)。
|
||||||
|
* 说得完就一句,真有两三样要说的就两三句 —— 摘不出就是空数组。
|
||||||
|
*/
|
||||||
|
readonly detail: readonly string[];
|
||||||
readonly eventRef: string | undefined;
|
readonly eventRef: string | undefined;
|
||||||
readonly position: string | undefined;
|
readonly position: string | undefined;
|
||||||
};
|
};
|
||||||
@@ -88,7 +102,9 @@ const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
|
|||||||
* 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。
|
* 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。
|
||||||
*/
|
*/
|
||||||
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
|
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
|
||||||
observation: (e) => e.title,
|
// 带交代的内部痕:标题摆交代的**首句**(原标题是「这一拍在往下办事…」那句机器话;
|
||||||
|
// 全文归抽屉 —— 一行摆全文会把这一屏撑成散文集,而这一屏的用法是扫)
|
||||||
|
observation: (e) => (typeof e.detail?.['say'] === 'string' ? firstSentence(e.detail['say']) : e.title),
|
||||||
thought: (e) => e.title,
|
thought: (e) => e.title,
|
||||||
action: (e) => actionEntryOf(e).body,
|
action: (e) => actionEntryOf(e).body,
|
||||||
result: (e) => e.title,
|
result: (e) => e.title,
|
||||||
@@ -101,7 +117,8 @@ const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
|
|||||||
'release-verdict': (e) => plain(e.title),
|
'release-verdict': (e) => plain(e.title),
|
||||||
terminal: (e) => plain(e.title),
|
terminal: (e) => plain(e.title),
|
||||||
'human-input': (e) => e.title,
|
'human-input': (e) => e.title,
|
||||||
narration: (e) => e.title,
|
// 交代一律「首句上行、全文进抽屉」—— 进没进对话区都一样,这一屏的用法是扫
|
||||||
|
narration: (e) => firstSentence(e.title),
|
||||||
control: (e) => e.title,
|
control: (e) => e.title,
|
||||||
trigger: (e) => e.title,
|
trigger: (e) => e.title,
|
||||||
};
|
};
|
||||||
@@ -210,7 +227,8 @@ function actionOf(e: MemoryEntry): string {
|
|||||||
case 'trigger':
|
case 'trigger':
|
||||||
return '触发事件';
|
return '触发事件';
|
||||||
case 'observation':
|
case 'observation':
|
||||||
return '系统事件';
|
// 带交代的内部痕 = AI 这一拍的交代(没进对话区),章与对话区同词
|
||||||
|
return typeof d['say'] === 'string' ? '交代进展' : '系统事件';
|
||||||
case 'engine-transition':
|
case 'engine-transition':
|
||||||
return transitionOf(e.title).action;
|
return transitionOf(e.title).action;
|
||||||
case 'action':
|
case 'action':
|
||||||
@@ -254,8 +272,12 @@ function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined;
|
|||||||
const by = e.detail?.['by'];
|
const by = e.detail?.['by'];
|
||||||
const id = typeof by === 'string' && by !== '' ? by : undefined;
|
const id = typeof by === 'string' && by !== '' ? by : undefined;
|
||||||
switch (e.kind) {
|
switch (e.kind) {
|
||||||
case 'trigger':
|
|
||||||
case 'observation':
|
case 'observation':
|
||||||
|
// 带交代的那种是 **AI 说的话**,记在它名下;其余系统事件照旧署系统
|
||||||
|
return typeof e.detail?.['say'] === 'string'
|
||||||
|
? { actor: '任务助手', actorId: undefined, who: 'agent' as const }
|
||||||
|
: { actor: '系统', actorId: undefined, who: 'system' as const };
|
||||||
|
case 'trigger':
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
return { actor: '系统', actorId: undefined, who: 'system' };
|
return { actor: '系统', actorId: undefined, who: 'system' };
|
||||||
/*
|
/*
|
||||||
@@ -317,7 +339,17 @@ function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined;
|
|||||||
const RESTATED: ReadonlyArray<RegExp> = [/^落地收敛[::]\s*合并/, /^落地收敛,发射输出口/];
|
const RESTATED: ReadonlyArray<RegExp> = [/^落地收敛[::]\s*合并/, /^落地收敛,发射输出口/];
|
||||||
|
|
||||||
function machinery(e: MemoryEntry): boolean {
|
function machinery(e: MemoryEntry): boolean {
|
||||||
if (e.detail?.['internal'] === true) return true;
|
/*
|
||||||
|
* **被压下的每拍交代要留在工作日志里**(2026-08-03 用户:「细节展开不够好」)。
|
||||||
|
*
|
||||||
|
* 对话区一轮一句:只有交出控制权那一拍的交代摆到人面前,埋头办事那几拍的
|
||||||
|
* 「留进工作日志」—— 这是 2026-08-01 立的口径,快照里也是这么写的。
|
||||||
|
* 可实现是 `internal === true` 一律滤掉:那几句**哪儿都看不到**,
|
||||||
|
* 白烧的 token 不说,人想回看「这一拍它在干什么」时一片空白。
|
||||||
|
* 现在带 `say` 的放行(它是人话,正是要留的那样东西);
|
||||||
|
* 其余内部痕(重问原文这类给排障用的)照旧不摆。
|
||||||
|
*/
|
||||||
|
if (e.detail?.['internal'] === true && typeof e.detail?.['say'] !== 'string') return true;
|
||||||
if (RESTATED.some((re) => re.test(e.title))) return true;
|
if (RESTATED.some((re) => re.test(e.title))) return true;
|
||||||
if (e.kind === 'thought') return true;
|
if (e.kind === 'thought') return true;
|
||||||
if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;
|
if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;
|
||||||
@@ -331,6 +363,130 @@ function machinery(e: MemoryEntry): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首句 —— 交代常有三四句,一行只摆得下第一句(其余进抽屉)。
|
||||||
|
* 断在句号/分号/换行,断不出就按字数截;**截了才加省略号**,没截不加
|
||||||
|
* (加了等于告诉人「后面还有」,而后面没有)。
|
||||||
|
*/
|
||||||
|
export function firstSentence(text: string, cap = 42): string {
|
||||||
|
const clean = text.trim().replace(/\s+/g, ' ');
|
||||||
|
const cut = clean.search(/[。;;\n!!??]/);
|
||||||
|
const head = cut > 0 && cut + 1 <= cap ? clean.slice(0, cut + 1) : clean.slice(0, cap);
|
||||||
|
return head.length < clean.length ? `${head}…` : head;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡的种类:发的是哪一种人类工具 —— 摆人读名,不摆 `execution` 这类内部词 */
|
||||||
|
const CARD_KIND_LABEL: Readonly<Record<string, string>> = {
|
||||||
|
execution: '执行工单',
|
||||||
|
acceptance: '验收工单',
|
||||||
|
decision: '决策卡',
|
||||||
|
entry: '数据录入卡',
|
||||||
|
rating: '评价卡',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 送到哪儿了 —— 同上,内部路由名换人话 */
|
||||||
|
const ARRIVAL_LABEL: Readonly<Record<string, string>> = {
|
||||||
|
'task-chat': '这一单的对话区',
|
||||||
|
inbox: '收件箱',
|
||||||
|
'cross-task': '跨任务征答',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 值摆到日志上的样子:太长截断(细节是给人扫一眼的,不是把留痕抄一遍) */
|
||||||
|
function short(value: unknown, cap = 300): string {
|
||||||
|
const text =
|
||||||
|
typeof value === 'string'
|
||||||
|
? value
|
||||||
|
: typeof value === 'number' || typeof value === 'boolean'
|
||||||
|
? String(value)
|
||||||
|
: JSON.stringify(value);
|
||||||
|
const clean = (text ?? '').trim();
|
||||||
|
return clean.length > cap ? `${clean.slice(0, cap)}…` : clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主键名单摘成一句:`PN=A、PN=B` —— 「写了哪几行」是人回看时最想知道的一件事 */
|
||||||
|
function keyList(keys: unknown): string | undefined {
|
||||||
|
if (!Array.isArray(keys) || keys.length === 0) return undefined;
|
||||||
|
const rows = keys.slice(0, 8).map((one) =>
|
||||||
|
typeof one === 'object' && one !== null
|
||||||
|
? Object.values(one as Record<string, unknown>)
|
||||||
|
.map((v) => String(v))
|
||||||
|
.join(' / ')
|
||||||
|
: String(one),
|
||||||
|
);
|
||||||
|
return keys.length > 8 ? `${rows.join('、')}…(共 ${keys.length} 行)` : rows.join('、');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **展开看的那几句** —— 按 kind 各摘各的,认不出的一概不摆。
|
||||||
|
*
|
||||||
|
* 出的是**人话**不是字段:每句自带主语与语境(「发的是执行工单,送到这一单的对话区」),
|
||||||
|
* 单拎出来也读得通。纪律三条:①不摆系统内的英文名(卡种/送达位置都换人话)
|
||||||
|
* ②不把留痕整段抄上去(长值截断)③摘不出就返回空数组 ——
|
||||||
|
* 前端据此不给展开钮,**没有细节就别摆一个空抽屉**。
|
||||||
|
*/
|
||||||
|
export function detailOf(e: MemoryEntry): readonly string[] {
|
||||||
|
const d = e.detail ?? {};
|
||||||
|
const out: string[] = [];
|
||||||
|
const say = (text: string | undefined): void => {
|
||||||
|
if (text !== undefined && text.trim() !== '') out.push(text.trim());
|
||||||
|
};
|
||||||
|
const has = (v: unknown): v is string | number | boolean =>
|
||||||
|
v !== undefined && v !== null && v !== '' && (typeof v === 'string' ? v.trim() !== '' : true);
|
||||||
|
|
||||||
|
switch (e.kind) {
|
||||||
|
case 'engine-transition':
|
||||||
|
// 「凭什么算办完了」—— 收步那一下的理由,人回看时最想核的就是它
|
||||||
|
if (has(d['rationale'])) say(`算办完的依据:${short(d['rationale'], 400)}`);
|
||||||
|
break;
|
||||||
|
case 'result':
|
||||||
|
// 被拒/失败:理由与下一步(拒回的话本来就是写给下一拍的)
|
||||||
|
if (has(d['reason'] ?? d['error'])) say(`没通过的缘由:${short(d['reason'] ?? d['error'], 500)}`);
|
||||||
|
if (has(d['hint'])) say(`接下来该:${short(d['hint'], 300)}`);
|
||||||
|
break;
|
||||||
|
case 'card-sent': {
|
||||||
|
const kind = CARD_KIND_LABEL[String(d['mode'] ?? d['k'] ?? '')];
|
||||||
|
const arrival = ARRIVAL_LABEL[String(d['arrival'] ?? '')];
|
||||||
|
if (kind !== undefined) say(arrival === undefined ? `发的是${kind}。` : `发的是${kind},送到${arrival}。`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'card-reply':
|
||||||
|
if (has(d['summary'])) say(`他填的是:${short(d['summary'], 400)}`);
|
||||||
|
if (has(d['value'] ?? d['chosen'])) say(`他选的是:${short(d['value'] ?? d['chosen'], 200)}`);
|
||||||
|
break;
|
||||||
|
case 'ledger-write': {
|
||||||
|
if (has(d['from'])) say(`把${short(d['label'] ?? '这一格', 40)}从「${short(d['from'], 80)}」改成「${short(d['to'], 80)}」。`);
|
||||||
|
const rows = keyList(d['keys']);
|
||||||
|
if (rows !== undefined) say(`落下这几行:${rows}`);
|
||||||
|
else if (has(d['rows'])) say(`落下 ${short(d['rows'])} 行。`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'release-verdict': {
|
||||||
|
const verdict = d['verdict'];
|
||||||
|
if (verdict === 'pass') say('裁决:通过放行。');
|
||||||
|
else if (verdict === 'redo') say('裁决:打回重做。');
|
||||||
|
if (has(d['comment'])) say(`他的意见:${short(d['comment'], 300)}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'narration': {
|
||||||
|
// 首句已经上行了,这儿摆全文;一句话说完的不重复摆(点开发现一模一样等于白点)
|
||||||
|
const full = e.title.trim().replace(/\s+/g, ' ');
|
||||||
|
if (firstSentence(e.title) !== full) say(short(e.title, 600));
|
||||||
|
say(d['internal'] === true ? '这句只留在工作日志,没进对话区 —— 那一拍没有交出控制权。' : '这句已进对话区。');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'observation': {
|
||||||
|
// 被压下的每拍交代:全文在这儿(标题只摆得下第一句)
|
||||||
|
const text = d['say'];
|
||||||
|
if (typeof text === 'string' && firstSentence(text) !== text.trim().replace(/\s+/g, ' ')) say(short(text, 600));
|
||||||
|
if (has(d['raw'])) say(`留档原文:${short(d['raw'], 400)}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] {
|
export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] {
|
||||||
return entries
|
return entries
|
||||||
.filter((e) => SKELETON.has(e.kind) && !machinery(e))
|
.filter((e) => SKELETON.has(e.kind) && !machinery(e))
|
||||||
@@ -343,6 +499,7 @@ export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] {
|
|||||||
...actorOf(e),
|
...actorOf(e),
|
||||||
title: TEMPLATE[e.kind](e),
|
title: TEMPLATE[e.kind](e),
|
||||||
summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined,
|
summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined,
|
||||||
|
detail: detailOf(e),
|
||||||
eventRef: e.eventRef,
|
eventRef: e.eventRef,
|
||||||
position: e.stamp.position,
|
position: e.stamp.position,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3139,6 +3139,18 @@
|
|||||||
.proto-root .jc-log-t { flex: 1; min-width: 0; }
|
.proto-root .jc-log-t { flex: 1; min-width: 0; }
|
||||||
.proto-root .jc-log-t b { display: block; font-size: 12.5px; font-weight: 600; }
|
.proto-root .jc-log-t b { display: block; font-size: 12.5px; font-weight: 600; }
|
||||||
.proto-root .jc-log-t em { display: block; margin-top: 2px; font-style: normal; font-size: 11px; line-height: 1.6; color: var(--ink-3); }
|
.proto-root .jc-log-t em { display: block; margin-top: 2px; font-style: normal; font-size: 11px; line-height: 1.6; color: var(--ink-3); }
|
||||||
|
/* 细节抽屉(2026-08-03):折叠时一行不变,展开才现;没有细节的条目连钮都不给 */
|
||||||
|
.proto-root .jc-log-more { display: flex; align-items: baseline; gap: 6px; width: 100%; padding: 0; border: 0; background: none; text-align: left; cursor: pointer; color: inherit; font: inherit; }
|
||||||
|
.proto-root .jc-log-more > svg { flex: none; margin-top: 1px; color: var(--ink-4); transition: color var(--t-fast); }
|
||||||
|
.proto-root .jc-log-more:hover > b { color: var(--brand-deep); }
|
||||||
|
.proto-root .jc-log-more:hover > svg { color: var(--brand); }
|
||||||
|
.proto-root .jc-log-more:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 4px; }
|
||||||
|
/* 展开的正文:一段一句,左缘一道细线把它归到这一条名下 —— 不做键值两栏 */
|
||||||
|
.proto-root .jc-log-body { margin: 6px 0 2px; padding: 2px 0 2px 10px; border-left: 2px solid var(--line); }
|
||||||
|
.proto-root .jc-log-body p { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.proto-root .jc-log-body p + p { margin-top: 5px; }
|
||||||
|
/* 展开那一条轻轻提亮,眼睛回得来 */
|
||||||
|
.proto-root .jc-log-row.on { background: #fbfcfe; border-radius: 8px; }
|
||||||
/* 成果二级界面的返回键 —— 顶部页签不变,右槽换成它 */
|
/* 成果二级界面的返回键 —— 顶部页签不变,右槽换成它 */
|
||||||
.proto-root .jc-tnav-back { width: auto; padding: 0 9px; gap: 5px; font-size: 11.5px; font-weight: 600; }
|
.proto-root .jc-tnav-back { width: auto; padding: 0 9px; gap: 5px; font-size: 11.5px; font-weight: 600; }
|
||||||
|
|
||||||
|
|||||||
@@ -427,16 +427,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
|||||||
* (自主率要把「等外面」与「机器干的」分开);人扫这一屏只问
|
* (自主率要把「等外面」与「机器干的」分开);人扫这一屏只问
|
||||||
* 「这一步是我办的、AI 办的,还是流程自己走到的」。
|
* 「这一步是我办的、AI 办的,还是流程自己走到的」。
|
||||||
*/
|
*/
|
||||||
<div key={line.id} className={'jc-log-row t-' + line.who}>
|
<LogRow key={line.id} line={line} />
|
||||||
<span className="jc-log-at jc-mono">{atMinute(line.at)}</span>
|
|
||||||
{/* 章摆的是**动作**(四字,与对话区同词),不是归侧 ——
|
|
||||||
归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */}
|
|
||||||
<span className="jc-log-side">{line.action}</span>
|
|
||||||
<span className="jc-log-t">
|
|
||||||
<b>{line.title}</b>
|
|
||||||
{line.summary !== undefined && <em>{line.summary}</em>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -652,6 +643,53 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
|
|||||||
* 涟漪说「环在转」,微光工作线说「报告正在一行行写出来」;
|
* 涟漪说「环在转」,微光工作线说「报告正在一行行写出来」;
|
||||||
* 都是纯 CSS、只动 transform/opacity,prefers-reduced-motion 下静止但状态仍可读。
|
* 都是纯 CSS、只动 transform/opacity,prefers-reduced-motion 下静止但状态仍可读。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 日志一行 —— **折叠时一行,展开才看细节**(2026-08-03 用户:「细节展开不够好」)。
|
||||||
|
*
|
||||||
|
* 这一屏的用法是**扫**:一眼看完这单走过哪几步、谁办的。所以密度不能破 ——
|
||||||
|
* 细节收进抽屉,想深究哪一条点哪一条。**没有细节的条目不给展开钮**:
|
||||||
|
* 摆一个点开是空的抽屉,比不给还糟(点一次才知道白点)。
|
||||||
|
*
|
||||||
|
* 展开不做动画高度:日志动辄几十条,一条一个过渡就是一屏都在抖。
|
||||||
|
*/
|
||||||
|
function LogRow({ line }: { line: TaskDetail['log'][number] }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const has = line.detail.length > 0;
|
||||||
|
return (
|
||||||
|
<div className={'jc-log-row t-' + line.who + (open ? ' on' : '')}>
|
||||||
|
<span className="jc-log-at jc-mono">{atMinute(line.at)}</span>
|
||||||
|
{/* 章摆的是**动作**(四字,与对话区同词),不是归侧 ——
|
||||||
|
归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */}
|
||||||
|
<span className="jc-log-side">{line.action}</span>
|
||||||
|
<span className="jc-log-t">
|
||||||
|
{has ? (
|
||||||
|
<button type="button" className="jc-log-more" onClick={() => setOpen(!open)} aria-expanded={open}>
|
||||||
|
<b>{line.title}</b>
|
||||||
|
<Ico name={open ? 'chevron-down' : 'chevron-right'} s={13} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<b>{line.title}</b>
|
||||||
|
)}
|
||||||
|
{line.summary !== undefined && <em>{line.summary}</em>}
|
||||||
|
{/*
|
||||||
|
* **展开就是把话说完,不摆二级条目**(2026-08-03 用户定)。
|
||||||
|
*
|
||||||
|
* 从前展开出来是一摞「键:值」—— 那是把留痕的字段名搬到人面前,
|
||||||
|
* 人还得自己在脑子里把它们连成一句话。日志是给人读的:
|
||||||
|
* 该说的直接写成正文,一段说完;真有两三样要说的,一样一段。
|
||||||
|
*/}
|
||||||
|
{open && (
|
||||||
|
<div className="jc-log-body">
|
||||||
|
{line.detail.map((text, i) => (
|
||||||
|
<p key={i}>{text}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WorkingScene({ doneN }: { doneN: number }) {
|
function WorkingScene({ doneN }: { doneN: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="jc-working" aria-live="polite">
|
<div className="jc-working" aria-live="polite">
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ export const taskDetailSchema = z.object({
|
|||||||
/** 内容,**带主语**:`任务助手:…` / `柴浩:…` —— 日志不写没头脑的流水账 */
|
/** 内容,**带主语**:`任务助手:…` / `柴浩:…` —— 日志不写没头脑的流水账 */
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
summary: z.string().optional(),
|
summary: z.string().optional(),
|
||||||
|
/**
|
||||||
|
* **展开才看的细节 —— 一段一句人话**(2026-08-03 用户:「不要写二级条目,
|
||||||
|
* 直接正文描述完」):凭什么算这一步办完了、那一笔落下哪几行、被拒拒在哪儿。
|
||||||
|
* 每句自带主语与语境,单拎出来也读得通;不摆系统内的英文名。
|
||||||
|
* 摘不出东西就是空数组 —— 前端据此**不给展开钮**,没有细节不摆空抽屉。
|
||||||
|
*/
|
||||||
|
detail: z.array(z.string()).default([]),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user