基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 20:43:35 +08:00
co-authored by Claude Fable 5
parent 61fe181678
commit ac85ecf2ea
119 changed files with 16634 additions and 1246 deletions
+4 -1
View File
@@ -10,6 +10,7 @@
- 端口类型:`InteractionStore` `HumanEventPublisher` `MemoryRecorder` `Audience` `Clock` `IdGenerator`
- 用例工厂:`createInteractionService(deps): InteractionService`
- 纯函数:`outputControl` `inputControl` `handoverHappens` `checkTemplate` `templateIsSafe`
`projectChat`(任务记忆 → 对话消息的筛选投影)
- adapter:`createInteractionStore`;Drizzle 表:`chatMessageTable` `taskViewTable`
层级: L3(编码规范 §4)
@@ -24,4 +25,6 @@
转交随引擎事件自动发生,活动不感知交接
- **视图区无脚本执行权**:含 `<script>` / `on*` 事件处理器 / 外部资源的模版一律 `TemplateRejectedError`
- **前端零过程状态**:对话流与视图都是服务端投影,刷新即重建;SSE 只推失效与内容,不推状态机
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道;
AI 发言与系统事件**不另设写入口**,一律由 `projectChat` 从任务记忆筛出 ——
对话流与工作日志同源不同筛选,引擎自转的条目不进对话区
@@ -0,0 +1,62 @@
/**
* 对话流投影 —— 「对话区只承载非结构化消息 + 决策选择题」(AM 07)。
* 这里守的是**筛选口径**:引擎自转不进对话,人的话不重影,工单卡带得上指涉。
*/
import { describe, expect, it } from 'vitest';
import type { NarrativeEntry } from '../internal/domain/chat-projection.ts';
import { projectChat } from '../internal/domain/chat-projection.ts';
const entry = (kind: string, over: Partial<NarrativeEntry> = {}): NarrativeEntry => ({
id: `e-${kind}`,
at: '2026-07-27T10:00:00.000Z',
kind,
title: `${kind} 标题`,
...over,
});
describe('projectChat —— 对话流与工作日志同源不同筛选', () => {
it('引擎自转的条目一律不进对话区', () => {
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'card-reply', 'control'];
expect(projectChat(noise.map((k) => entry(k)))).toEqual([]);
});
it('人的自由输入不投影 —— say 已直接落成消息,再投一遍就是重影', () => {
expect(projectChat([entry('human-input', { title: '按 A 方案办' })])).toEqual([]);
});
it('事件到达投成系统事件,类型认得就用、认不得按数据事件计', () => {
const out = projectChat([
entry('observation', { id: 'o1', detail: { kind: 'time' } }),
entry('observation', { id: 'o2', detail: { kind: '莫名其妙' } }),
entry('observation', { id: 'o3' }),
]);
expect(out.map((m) => (m.k === 'system' ? m.type : m.k))).toEqual(['time', 'data', 'data']);
});
it('发工单投成 AI 发言并带上指涉 —— 卡在对话流上出现,点开即定位视图区', () => {
const [msg] = projectChat([
entry('card-sent', { title: '请你执行:补齐定价说明', detail: { kind: 'work-order', id: 'wo-1' } }),
]);
expect(msg?.k).toBe('ai');
expect(msg?.k === 'ai' ? msg.ref : undefined).toBe('wo-1');
expect(msg?.k === 'ai' ? msg.tag : undefined).toBe('guidance');
});
it('验收件标判据、执行件标指导', () => {
const out = projectChat([
entry('card-sent', { id: 'a', title: '请你验收:定价方案', detail: { kind: 'work-order', id: 'wo-a' } }),
entry('card-sent', { id: 'b', title: '请你执行:补齐说明', detail: { kind: 'work-order', id: 'wo-b' } }),
]);
expect(out.map((m) => (m.k === 'ai' ? m.tag : undefined))).toEqual(['criterion', 'guidance']);
});
it('跨人卡不在本任务对话流上露面 —— 它进对方的消息要素', () => {
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' })]);
expect(out).toHaveLength(2);
expect(out.every((m) => m.k === 'ai' && m.tag === 'conclusion')).toBe(true);
});
});
@@ -0,0 +1,71 @@
/**
* 对话流投影 —— 把任务记忆里**叙事性**的条目投成对话消息(纯函数,无 IO)。
*
* 「对话区只承载非结构化消息 + 决策选择题;事件由系统 Agent(灰头像)发进来」(AM 07)。
* 对话流与工作日志**同源不同筛选**:日志要全,对话只要人读得懂的那几类 ——
* 引擎自转(engine-transition / evaluation / action / ledger-write …)不进对话,
* 否则对话区就成了机器踪迹的副本,失去「非结构化消息」的定位。
*
* 筛四类:
* observation → 系统事件(灰头像)—— 三类唤醒事件里的外部到达
* card-sent → AI 发言 + 指涉工单(卡在对话流上出现,点开即定位到视图区)
* release-verdict→ AI 发言 · 阶段结论
* terminal → AI 发言 · 阶段结论
* human-input 不投:人的话已由 `say` 直接落成消息,再投一遍就是重影。
*/
import type { ChatMessage, EventType } from '@contracts';
import { cardIdSchema, workOrderIdSchema } from '@contracts';
/** 记忆条目的只读切面 —— 投影只要这几样,不牵入 memory 的完整类型 */
export type NarrativeEntry = {
readonly id: string;
readonly at: string;
readonly kind: string;
readonly title: string;
readonly detail?: Record<string, unknown>;
};
const EVENT_TYPES: readonly EventType[] = ['data', 'time', 'human', 'control'];
/** 事件到达条目的类型:detail.kind 认得就用它,认不得按数据事件计 */
function eventTypeOf(detail: Record<string, unknown> | undefined): EventType {
const kind = detail?.['kind'];
return typeof kind === 'string' && (EVENT_TYPES as readonly string[]).includes(kind)
? (kind as EventType)
: 'data';
}
/** 工单卡的指涉:card-sent 的 detail 里带着 `{kind:'work-order', id}` */
function workOrderRef(detail: Record<string, unknown> | undefined): string | undefined {
return detail?.['kind'] === 'work-order' && typeof detail['id'] === 'string' ? detail['id'] : undefined;
}
export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
const out: ChatMessage[] = [];
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 });
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),
});
continue;
}
if (e.kind === 'release-verdict' || e.kind === 'terminal') {
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: 'conclusion' });
}
}
return out;
}
@@ -8,6 +8,7 @@ import { TemplateRejectedError } from '../../contract.ts';
import type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStore, MemoryRecorder } from '../../port.ts';
import type { InteractionPhase } from '../domain/control-handover.ts';
import { checkTemplate, outputControl } from '../domain/control-handover.ts';
import { projectChat } from '../domain/chat-projection.ts';
export type InteractionServiceDeps = {
readonly store: InteractionStore;
@@ -35,8 +36,16 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
}
return {
/**
* 对话流 = 已落库的消息(人的话)+ 任务记忆的叙事投影(AI 发言 / 系统事件),按时间全序。
* AI 与系统**不另设写入口** —— 同源不同筛选,对话区与工作日志永远说同一件事。
*/
async timeline(taskId) {
return deps.store.listMessages(taskId);
const [stored, entries] = await Promise.all([
deps.store.listMessages(taskId),
deps.memory.history(taskId),
]);
return [...stored, ...projectChat(entries)].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
},
/** 负责人的自由输入 —— 人事件的第二形态;写入任务记忆,不驱动 flow 步 */
+6
View File
@@ -3,6 +3,7 @@
*/
import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts';
import type { InteractionPhase } from './internal/domain/control-handover.ts';
import type { NarrativeEntry } from './internal/domain/chat-projection.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
@@ -24,6 +25,11 @@ export type HumanEventPublisher = {
/** 留痕 —— app 注入 `memory`(L2):时间线即任务记忆的可读投影 */
export type MemoryRecorder = {
append(input: { taskId: TaskId; kind: 'human-input'; title: string; detail?: Record<string, unknown> }): Promise<void>;
/**
* 叙事条目 —— 对话流与工作日志**同源不同筛选**:
* AI 发言与系统事件不另设写入口,一律从任务记忆投影而来(见 `chat-projection`)。
*/
history(taskId: TaskId): Promise<readonly NarrativeEntry[]>;
};
/** 收件人解析 —— app 注入 `org`(L1):SSE 只推给有权看的人 */