/** * interaction 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。 */ import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts'; import { cardIdSchema } from '@contracts'; import type { InteractionService, PushEnvelope, Subscription } from '../../contract.ts'; 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'; export type InteractionServiceDeps = { readonly store: InteractionStore; readonly events: HumanEventPublisher; readonly memory: MemoryRecorder; readonly audience: Audience; readonly clock: Clock; readonly ids: IdGenerator; }; type Subscriber = { staffId: StaffId; onPush: (envelope: PushEnvelope) => void }; export function createInteractionService(deps: InteractionServiceDeps): InteractionService { // SSE 订阅者表是**连接级**状态,不是过程状态:进程内持有,刷新即从投影重建(§9.2) const subscribers = new Set(); function broadcast(envelope: PushEnvelope): void { for (const subscriber of subscribers) { try { subscriber.onPush(envelope); } catch { subscribers.delete(subscriber); } } } return { async timeline(taskId) { return deps.store.listMessages(taskId); }, /** 负责人的自由输入 —— 人事件的第二形态;写入任务记忆,不驱动 flow 步 */ async say(taskId: TaskId, by: StaffId, text: string): Promise { const message: ChatMessage = { k: 'human', id: cardIdSchema.parse(deps.ids.next()), at: deps.clock.now().toISOString(), text, by, }; await deps.store.appendMessage(taskId, message); await deps.memory.append({ taskId, kind: 'human-input', title: text, detail: { by } }); await deps.events.publishHumanEvent({ taskId, cardId: message.id, payload: { kind: 'free-input', text } }); broadcast({ kind: 'chat', taskId, message }); return message; }, async pushView(taskId: TaskId, blocks: readonly ViewBlock[]) { await deps.store.putView(taskId, { blocks }); broadcast({ kind: 'view', taskId, blocks }); }, /** 视图模版过净化门:含脚本 / 事件处理器 / 外部资源一律拒推 */ async pushViewHtml(taskId, html) { const violations = checkTemplate(html); if (violations.length > 0) throw new TemplateRejectedError(violations); await deps.store.putView(taskId, { html }); broadcast({ kind: 'view-html', taskId, html }); }, async viewOf(taskId) { return deps.store.getView(taskId); }, async setPhase(taskId: TaskId, phase: InteractionPhase) { await deps.store.setPhase(taskId, phase); const output = outputControl(phase); broadcast({ kind: 'control', taskId, phase, output }); return output; }, async phaseOf(taskId) { return (await deps.store.phaseOf(taskId)) ?? 'activating'; }, subscribe(staffId, onPush) { const subscriber: Subscriber = { staffId, onPush }; subscribers.add(subscriber); return { close: () => void subscribers.delete(subscriber) } satisfies Subscription; }, invalidate(target, id) { broadcast({ kind: 'invalidate', target, id }); }, }; } /** 供 app 的 SSE 路由复用:按可见性过滤(避免把别人的任务推给不相干的人) */ export async function visibleTo(audience: Audience, staffId: StaffId, taskId: TaskId): Promise { return audience.canSee(staffId, taskId); }