This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/api/src/modules/interaction/internal/usecase/interaction-service.ts
T

111 lines
4.2 KiB
TypeScript

/**
* 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';
import { projectChat } from '../domain/chat-projection.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<Subscriber>();
function broadcast(envelope: PushEnvelope): void {
for (const subscriber of subscribers) {
try {
subscriber.onPush(envelope);
} catch {
subscribers.delete(subscriber);
}
}
}
return {
/**
* 对话流 = 已落库的消息(人的话)+ 任务记忆的叙事投影(AI 发言 / 系统事件),按时间全序。
* AI 与系统**不另设写入口** —— 同源不同筛选,对话区与工作日志永远说同一件事。
*/
async timeline(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 步 */
async say(taskId: TaskId, by: StaffId, text: string): Promise<ChatMessage> {
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<boolean> {
return audience.canSee(staffId, taskId);
}